diff --git a/.claude/skills/ha-pr-reviewer/SKILL.md b/.claude/skills/ha-pr-reviewer/SKILL.md index 35c2ecd81781..05060b3de2f0 100644 --- a/.claude/skills/ha-pr-reviewer/SKILL.md +++ b/.claude/skills/ha-pr-reviewer/SKILL.md @@ -8,7 +8,7 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c ## Instructions: - Use 'gh pr view' to get the PR details and description. - Use 'gh pr diff' to see all the changes in the PR. -- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. +- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base. - Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill. ## IMPORTANT: diff --git a/.claude/skills/ha-review/SKILL.md b/.claude/skills/ha-review/SKILL.md index f78cbe0dfd5f..12e7cb4318df 100644 --- a/.claude/skills/ha-review/SKILL.md +++ b/.claude/skills/ha-review/SKILL.md @@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb # Review Code Changes +## Scope: +- Unless instructed otherwise, review the full branch changes against the target branch. Resolve the base to an available ref (prefer `upstream/`, then `origin/`, then local ``) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"..HEAD`; use `dev` as the default base. + ## Analyze the code changes for: - Code quality and style consistency - Potential bugs or issues diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 080b4e0a1d2f..4e1287cc395d 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@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:python" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d46a16ed9bd6..97fd2dfc6fdf 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -9,9 +9,6 @@ on: default: "dev" required: true -env: - STARTUP_TIMEOUT_SECONDS: 300 - permissions: {} concurrency: @@ -33,62 +30,65 @@ jobs: - arch: aarch64 runs-on: ubuntu-24.04-arm env: - IMAGE: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} BASE_URL: http://localhost:8123 - CURL_OPTS: --silent --max-time 10 + services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images] + ports: + - 8123:8123 + # Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget) + options: >- + --health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/" + --health-start-period=10s + --health-interval=5s + --health-retries=60 steps: - - name: Pull image - id: pull - run: | - docker pull "$IMAGE" - docker image inspect -f 'Testing {{index .RepoDigests 0}} ({{.Os}}/{{.Architecture}}), created {{.Created}}' "$IMAGE" + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: Start container - run: | - docker run -d --name homeassistant -p 8123:8123 "$IMAGE" + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: tests/e2e/package.json - - name: Wait for Home Assistant to start - run: | - timeout=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) - while ! curl $CURL_OPTS --fail --output /dev/null "$BASE_URL/"; do - if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then - echo "::error::Container exited before Home Assistant started" - exit 1 - fi - if [ "$SECONDS" -ge "$timeout" ]; then - echo "::error::Home Assistant did not respond on port 8123 within ${STARTUP_TIMEOUT_SECONDS}s" - exit 1 - fi - sleep 5 - done + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: tests/e2e/pnpm-lock.yaml - - name: Check frontend is served - run: | - # Pre-onboarding, / redirects to /onboarding.html; --location follows it - status=$(curl $CURL_OPTS --location --output /dev/null --write-out '%{http_code}' "$BASE_URL/") - if [ "$status" -ne 200 ]; then - echo "::error::Expected HTTP 200 from frontend, got $status" - exit 1 - fi + - name: Install E2E test dependencies + working-directory: tests/e2e + run: pnpm install --frozen-lockfile - - name: Check onboarding API responds - run: | - curl $CURL_OPTS --fail "$BASE_URL/api/onboarding" \ - | jq -e 'type == "array" and length > 0' + - name: Install Playwright browser + working-directory: tests/e2e + run: pnpm exec playwright install --with-deps chromium - - name: Check container is still running - run: | - if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then - echo "::error::Container is no longer running after checks" - exit 1 - fi + - name: Run Playwright E2E tests + working-directory: tests/e2e + run: pnpm exec playwright test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-${{ matrix.arch }} + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ - name: Dump container logs - if: always() && steps.pull.outcome == 'success' - run: docker logs homeassistant > homeassistant.log 2>&1 || true + if: always() + env: + CONTAINER: ${{ job.services.homeassistant.id }} + run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true - name: Upload container logs - if: always() && steps.pull.outcome == 'success' + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: container-logs-${{ matrix.arch }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 06f1638125f6..91798343783d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: # - Issues # - No issues marked as no-stale or help-wanted - name: 60 days stale PRs policy and 90 days stale issue policy - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: repo-token: ${{ steps.token.outputs.token }} remove-stale-when-updated: true diff --git a/.gitignore b/.gitignore index 9d8cbaf15e09..5fb2ad904d14 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,7 @@ pytest_buckets.txt .claude/worktrees/ .serena/ +# Playwright e2e tests +tests/e2e/node_modules/ +tests/e2e/playwright-report/ +tests/e2e/test-results/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 191130dd5c05..bd15be304e0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.20 + rev: v0.15.21 hooks: - id: ruff-check args: diff --git a/.prettierignore b/.prettierignore index c63290996661..54c2d65e4d62 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize-wrong.js tests/fixtures/core/config/yaml_errors/ +tests/e2e/pnpm-lock.yaml diff --git a/.strict-typing b/.strict-typing index e0f428e95d60..e3b571a69a8c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.* homeassistant.components.fully_kiosk.* homeassistant.components.fumis.* homeassistant.components.fyta.* +homeassistant.components.gatus.* homeassistant.components.generic_hygrostat.* homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* @@ -337,6 +338,7 @@ homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* +homeassistant.components.led_infrared.* homeassistant.components.lektrico.* homeassistant.components.letpot.* homeassistant.components.lg_infrared.* @@ -349,6 +351,7 @@ homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.linkplay.* homeassistant.components.litejet.* +homeassistant.components.litellm.* homeassistant.components.litterrobot.* homeassistant.components.llama_cpp.* homeassistant.components.local_ip.* @@ -382,7 +385,6 @@ homeassistant.components.min_max.* homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* -homeassistant.components.modbus_connection.* homeassistant.components.modem_callerid.* homeassistant.components.mold_indicator.* homeassistant.components.monzo.* diff --git a/CODEOWNERS b/CODEOWNERS index 024d8cc2c703..36a6953fdccb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,6 +625,8 @@ CLAUDE.md @home-assistant/core /tests/components/gardena_bluetooth/ @elupus /homeassistant/components/gate/ @home-assistant/core /tests/components/gate/ @home-assistant/core +/homeassistant/components/gatus/ @TN-1 +/tests/components/gatus/ @TN-1 /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 @@ -717,6 +719,8 @@ CLAUDE.md @home-assistant/core /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame /tests/components/hanna/ @bestycame +/homeassistant/components/harbor/ @Lash-L @afgarcia86 +/tests/components/harbor/ @Lash-L @afgarcia86 /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core @@ -1001,6 +1005,8 @@ CLAUDE.md @home-assistant/core /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco +/homeassistant/components/led_infrared/ @tr4nt0r +/tests/components/led_infrared/ @tr4nt0r /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom @@ -1032,6 +1038,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar +/homeassistant/components/litellm/ @luismalves +/tests/components/litellm/ @luismalves /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet @@ -1146,8 +1154,6 @@ CLAUDE.md @home-assistant/core /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus_connection/ @home-assistant/core -/tests/components/modbus_connection/ @home-assistant/core /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug @@ -1715,8 +1721,8 @@ CLAUDE.md @home-assistant/core /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn /tests/components/songpal/ @rytilahti @shenxn -/homeassistant/components/sonos/ @jjlawren @peterager -/tests/components/sonos/ @jjlawren @peterager +/homeassistant/components/sonos/ @peterager @jjlawren +/tests/components/sonos/ @peterager @jjlawren /homeassistant/components/soundtouch/ @kroimon /tests/components/soundtouch/ @kroimon /homeassistant/components/spaceapi/ @fabaff @@ -1965,6 +1971,8 @@ CLAUDE.md @home-assistant/core /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven +/homeassistant/components/vibration/ @home-assistant/core +/tests/components/vibration/ @home-assistant/core /homeassistant/components/vicare/ @CFenner @lackas /tests/components/vicare/ @CFenner @lackas /homeassistant/components/victron_ble/ @rajlaud diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 5313392d73a9..0c606c38d080 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -264,6 +264,7 @@ DEFAULT_INTEGRATIONS = { "occupancy", "power", "temperature", + "vibration", "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { diff --git a/homeassistant/components/aidot/coordinator.py b/homeassistant/components/aidot/coordinator.py index 7ec6a46ecd03..b751ac4af0f8 100644 --- a/homeassistant/components/aidot/coordinator.py +++ b/homeassistant/components/aidot/coordinator.py @@ -163,6 +163,4 @@ class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 89ff2a45f9ac..3a0dfa49742e 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -38,11 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: lat = data[CONF_LATITUDE] lng = data[CONF_LONGITUDE] - distance = data[CONF_RADIUS] # Check that the provided latitude/longitude provide a response try: - test_data = await client.observations.latLong(lat, lng, distance=distance) + test_data = await client.observations.latLong(lat, lng) except InvalidKeyError as exc: raise InvalidAuth from exc diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index f96c0e66a16e..020aecec00f8 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -77,7 +77,6 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): obs = await self.airnow.observations.latLong( self.latitude, self.longitude, - distance=self.distance, ) except (AirNowError, ClientConnectorError, InvalidJsonError) as error: diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index da1c936b68fb..fa321fe1a158 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.3.1"] + "requirements": ["pyairnow==1.4.0"] } diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py index 46bb3847219c..1e35887be249 100644 --- a/homeassistant/components/airobot/button.py +++ b/homeassistant/components/airobot/button.py @@ -4,11 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, override -from pyairobotrest.exceptions import ( - AirobotConnectionError, - AirobotError, - AirobotTimeoutError, -) +from pyairobotrest.exceptions import AirobotError from homeassistant.components.button import ( ButtonDeviceClass, @@ -32,7 +28,6 @@ class AirobotButtonEntityDescription(ButtonEntityDescription): """Describes Airobot button entity.""" press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] - ignore_connection_errors: bool = False BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( @@ -41,7 +36,6 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda coordinator: coordinator.client.reboot_thermostat(), - ignore_connection_errors=True, ), AirobotButtonEntityDescription( key="recalibrate_co2", @@ -86,14 +80,6 @@ class AirobotButton(AirobotEntity, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - except (AirobotConnectionError, AirobotTimeoutError) as err: - if not self.entity_description.ignore_connection_errors: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="button_press_failed", - translation_placeholders={"button": self.entity_description.key}, - ) from err - # Connection errors during reboot are expected as device restarts except AirobotError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/airobot/manifest.json b/homeassistant/components/airobot/manifest.json index 6a2e01f07325..76ef0a73e01e 100644 --- a/homeassistant/components/airobot/manifest.json +++ b/homeassistant/components/airobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["pyairobotrest"], "quality_scale": "platinum", - "requirements": ["pyairobotrest==0.3.0"] + "requirements": ["pyairobotrest==0.4.0"] } diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 516988da4510..1e5cf061a6bd 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -111,6 +111,4 @@ def remove_stale_devices( break if device_id and device_id not in all_device_ids: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 953743c66a6a..aa68ddbf5244 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(entry.entry_id) + dev_reg.async_clear_config_entry(entry.entry_id, entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(entry.entry_id) diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py index 47cf6aa5ea88..e7f2cdb540c8 100644 --- a/homeassistant/components/androidtv/diagnostics.py +++ b/homeassistant/components/androidtv/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 3d7c3ce41386..05f30d960b90 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER from .coordinator import AnthropicConfigEntry, AnthropicCoordinator @@ -137,7 +137,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -147,20 +147,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 3153ab89fb31..398f3ccef507 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["anthropic==0.108.0"] } diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 7aa6d4d8a7ac..175c35c8297f 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, "tracked_devices": [], } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml index 1532f08d9a82..ef20474b3130 100644 --- a/homeassistant/components/backblaze_b2/quality_scale.yaml +++ b/homeassistant/components/backblaze_b2/quality_scale.yaml @@ -123,8 +123,4 @@ rules: comment: | The b2sdk library does not support custom HTTP session injection. It manages HTTP connections internally through its own session management. - strict-typing: - status: exempt - comment: | - The b2sdk dependency does not include a py.typed file and is not PEP 561 compliant. - This is outside the integration's control as it's a third-party library requirement. + strict-typing: todo diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index a8807a062add..625b742164ad 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -62,9 +62,7 @@ async def async_setup_entry( if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in { remote.serial_number for remote in remotes }: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async_add_entities(new_entities=entities) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index ba7c768f24aa..aca1b550eada 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -29,6 +29,7 @@ BINARY_SENSOR_TYPES = ( ), BinarySensorEntityDescription( key="input", + translation_key="input", ), ) diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index fd277810369f..16ab7b4493d7 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -43,8 +43,6 @@ async def async_setup_entry( class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity): """Representation of BleBox buttons.""" - _attr_name = None - def __init__( self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button ) -> None: diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index 382d6c34ebe0..82f9cb4944f6 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -70,6 +70,16 @@ } }, "entity": { + "binary_sensor": { + "input": { "name": "Input" } + }, + "button": { + "close": { "name": "Close" }, + "down": { "name": "Down" }, + "fav": { "name": "Favorite" }, + "open": { "name": "Open" }, + "up": { "name": "Up" } + }, "light": { "channel": { "name": "Channel {index}" } }, "sensor": { "active_power": { "name": "Active power" }, diff --git a/homeassistant/components/blebox/update.py b/homeassistant/components/blebox/update.py index e7e0088d3290..389b0560c6fe 100644 --- a/homeassistant/components/blebox/update.py +++ b/homeassistant/components/blebox/update.py @@ -22,7 +22,7 @@ from .const import DOMAIN from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 1f8caa91eeda..75cab39cbaa8 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -94,7 +94,7 @@ from .const import ( ) from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import BluetoothCallback, BluetoothChange +from .models import BluetoothCallback, BluetoothCallbackReplay, BluetoothChange from .storage import BluetoothStorage from .util import adapter_title, resolve_scanning_mode @@ -109,6 +109,7 @@ __all__ = [ "BaseHaScanner", "BluetoothCallback", "BluetoothCallbackMatcher", + "BluetoothCallbackReplay", "BluetoothChange", "BluetoothReachabilityIntent", "BluetoothScannerDevice", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 740f6456f76b..1d45d38b9caf 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -26,7 +26,12 @@ from homeassistant.helpers.singleton import singleton from .const import DATA_MANAGER from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher -from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback +from .models import ( + BluetoothCallback, + BluetoothCallbackReplay, + BluetoothChange, + ProcessAdvertisementCallback, +) if TYPE_CHECKING: from bleak.backends.device import BLEDevice @@ -143,6 +148,7 @@ def async_register_callback( *, scan_interval: float | None = None, scan_duration: float | None = None, + replay: BluetoothCallbackReplay = BluetoothCallbackReplay.OLDEST_FIRST, ) -> Callable[[], None]: """Register to receive a callback on bluetooth change. @@ -155,10 +161,13 @@ def async_register_callback( values. Without an address in the matcher the active-scan request is skipped; the callback itself still fires normally. + ``replay`` controls which cached advertisements are replayed to the + callback on registration; defaults to OLDEST_FIRST. + Returns a callback that can be used to cancel the registration. """ return _get_manager(hass).async_register_callback( - callback, match_dict, mode, scan_interval, scan_duration + callback, match_dict, mode, scan_interval, scan_duration, replay ) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 399c48dbe205..4a6c701ad8c6 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,9 +1,10 @@ """The bluetooth integration.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable from functools import partial import itertools import logging +from operator import attrgetter from typing import override from bleak_retry_connector import BleakSlotManager @@ -54,7 +55,12 @@ from .match import ( IntegrationMatcher, ble_device_matches, ) -from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak +from .models import ( + BluetoothCallback, + BluetoothCallbackReplay, + BluetoothChange, + BluetoothServiceInfoBleak, +) from .storage import BluetoothStorage from .util import async_load_history_from_system @@ -212,6 +218,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE, scan_interval: float | None = None, scan_duration: float | None = None, + replay: BluetoothCallbackReplay = BluetoothCallbackReplay.OLDEST_FIRST, ) -> Callable[[], None]: """Register a callback.""" callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) @@ -245,16 +252,37 @@ class HomeAssistantBluetoothManager(BluetoothManager): if cancel_active_scan is not None: cancel_active_scan() + if replay is not BluetoothCallbackReplay.DISABLED: + self._async_replay_history_for_callback( + connectable, callback, callback_matcher, replay + ) + return _async_remove_callback + + @hass_callback + def _async_replay_history_for_callback( + self, + connectable: bool, + callback: BluetoothCallback, + callback_matcher: BluetoothCallbackMatcherWithCallback, + replay: BluetoothCallbackReplay, + ) -> None: + """Replay history for a callback.""" # If we have history for the subscriber, we can trigger the callback # immediately with the last packet so the subscriber can see the # device. history = self._connectable_history if connectable else self._all_history - service_infos: Iterable[BluetoothServiceInfoBleak] = [] + service_infos: list[BluetoothServiceInfoBleak] = [] if (address := callback_matcher.get(ADDRESS)) is not None: if service_info := history.get(address): service_infos = [service_info] else: - service_infos = history.values() + # Sort by time explicitly; dict insertion order is not guaranteed + # to match advertisement time after history is loaded from storage. + service_infos = sorted( + history.values(), + key=attrgetter("time"), + reverse=replay is BluetoothCallbackReplay.NEWEST_FIRST, + ) for service_info in service_infos: if ble_device_matches(callback_matcher, service_info): @@ -263,8 +291,6 @@ class HomeAssistantBluetoothManager(BluetoothManager): except Exception: _LOGGER.exception("Error in bluetooth callback") - return _async_remove_callback - @hass_callback @override def async_stop(self, event: Event | None = None) -> None: diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index d8b51df9ec79..cf6cc46f60e4 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -1,10 +1,18 @@ """Models for bluetooth.""" from collections.abc import Callable -from enum import Enum +from enum import Enum, auto from home_assistant_bluetooth import BluetoothServiceInfoBleak BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") type BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] type ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] + + +class BluetoothCallbackReplay(Enum): + """Controls how history is replayed when a callback is registered.""" + + OLDEST_FIRST = auto() + NEWEST_FIRST = auto() + DISABLED = auto() diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 738d8d187fee..ee3be122bc55 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -176,9 +176,7 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index d167cda36fdc..65aec441be61 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -1,7 +1,6 @@ """Data update coordinator for caldav.""" from datetime import date, datetime, time, timedelta -from functools import partial import logging import re from typing import TYPE_CHECKING, override @@ -54,15 +53,17 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - # Get event list from the current calendar - vevent_list = await hass.async_add_executor_job( - partial( - self.calendar.search, - start=start_date, - end=end_date, - event=True, - expand=True, - ) + return await hass.async_add_executor_job(self._get_events, start_date, end_date) + + def _get_events( + self, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Fetch and parse events in a specific time frame.""" + vevent_list = self.calendar.search( + start=start_date, + end=end_date, + event=True, + expand=True, ) event_list = [] for event in vevent_list: @@ -96,16 +97,23 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): start_of_today = dt_util.start_of_local_day() start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) + event, offset = await self.hass.async_add_executor_job( + self._get_next_event, start_of_today, start_of_tomorrow + ) + self.offset = offset + return event + + def _get_next_event( + self, start_of_today: datetime, start_of_tomorrow: datetime + ) -> tuple[CalendarEvent | None, timedelta | None]: + """Fetch and parse the next matching event.""" # We have to retrieve the results for the whole day as the server # won't return events that have already started - results = await self.hass.async_add_executor_job( - partial( - self.calendar.search, - start=start_of_today, - end=start_of_tomorrow, - event=True, - expand=True, - ), + results = self.calendar.search( + start=start_of_today, + end=start_of_tomorrow, + event=True, + expand=True, ) # Create new events for each recurrence of an event that happens today. @@ -168,15 +176,13 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): len(vevents), self.calendar.name, ) - self.offset = None - return None + return None, None # Populate the entity attributes with the event values (summary, offset) = extract_offset( get_attr_value(vevent, "summary") or "", OFFSET ) - self.offset = offset - return CalendarEvent( + next_event = CalendarEvent( summary=summary, start=self.to_local(vevent.dtstart.value), end=self.to_local(self.get_end_date(vevent)), @@ -189,6 +195,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): else None ), ) + return next_event, offset @staticmethod def is_matching(vevent, search): diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 383e40c305e5..6f652339eb8f 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -60,6 +60,16 @@ async def async_setup_entry( ) +def _get_todo_items(calendar: caldav.Calendar) -> list[TodoItem]: + """Fetch and parse todo items.""" + results = calendar.search(todo=True, include_completed=True) + return [ + todo_item + for resource in results + if (todo_item := _todo_item(resource)) is not None + ] + + def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: """Convert a caldav Todo into a TodoItem.""" if ( @@ -108,18 +118,9 @@ class WebDavTodoListEntity(TodoListEntity): async def async_update(self) -> None: """Update To-do list entity state.""" - results = await self.hass.async_add_executor_job( - partial( - self._calendar.search, - todo=True, - include_completed=True, - ) + self._attr_todo_items = await self.hass.async_add_executor_job( + _get_todo_items, self._calendar ) - self._attr_todo_items = [ - todo_item - for resource in results - if (todo_item := _todo_item(resource)) is not None - ] @override async def async_create_todo_item(self, item: TodoItem) -> None: diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index dfb353b8719e..ade738d2f7f9 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.18.1"], + "requirements": ["pydaikin==2.18.2"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/denon_rs232/media_player.py b/homeassistant/components/denon_rs232/media_player.py index 4a7c5acaf631..062fe8ff9185 100644 --- a/homeassistant/components/denon_rs232/media_player.py +++ b/homeassistant/components/denon_rs232/media_player.py @@ -1,6 +1,7 @@ """Media player platform for the Denon RS-232 integration.""" -from typing import Literal, cast, override +import re +from typing import Any, Literal, cast, override from denon_rs232 import ( MIN_VOLUME_DB, @@ -13,13 +14,17 @@ from denon_rs232 import ( ) from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -71,6 +76,25 @@ INPUT_SOURCE_DENON_TO_HA: dict[InputSource, str] = { InputSource.DAB: "dab", } +TUNER_PRESETS_ROOT = "presets" +TUNER_FREQUENCY_MIN = 8750 +TUNER_FREQUENCY_MAX = 10800 +TUNER_FREQUENCY_LENGTH = 6 +#: Reported frequencies at or above this value are AM, which is not supported. +TUNER_FREQUENCY_FM_MAX = 50000 + + +def _tuner_frequency_to_mhz(frequency: str | None) -> str | None: + """Convert a reported tuner frequency to MHz, or None if it is not FM.""" + if frequency is None or not frequency.isdigit(): + return None + + value = int(frequency) + if value >= TUNER_FREQUENCY_FM_MAX: + return None + + return f"{value / 100:.2f}" + async def async_setup_entry( hass: HomeAssistant, @@ -138,7 +162,11 @@ class DenonRS232MediaPlayer(MediaPlayerEntity): if zone == "main": self._attr_name = None - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._attr_supported_features |= ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + ) else: self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3" @@ -172,6 +200,13 @@ class DenonRS232MediaPlayer(MediaPlayerEntity): source = self._player.input_source self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None + if source is InputSource.TUNER: + self._attr_media_channel = _tuner_frequency_to_mhz( + self._receiver.state.main_zone.tuner_frequency + ) + else: + self._attr_media_channel = None + volume_min = self._player.volume_min volume_max = self._player.volume_max if volume_min is not None: @@ -239,3 +274,62 @@ class DenonRS232MediaPlayer(MediaPlayerEntity): raise HomeAssistantError("Invalid source") await self._player.select_input_source(input_source) + + @override + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Tune to a tuner preset or an FM frequency.""" + if media_type != MediaType.CHANNEL: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={"media_type": str(media_type)}, + ) + + player = cast(MainPlayer, self._player) + if re.fullmatch(r"[A-G][1-8]", media_id): + await player.set_tuner_preset(media_id) + elif (match := re.fullmatch(r"0*([0-9]{1,5})", media_id)) and ( + TUNER_FREQUENCY_MIN <= (frequency := int(match[1])) <= TUNER_FREQUENCY_MAX + ): + await player.set_tuner_frequency(f"{frequency:0{TUNER_FREQUENCY_LENGTH}d}") + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_tuner_channel", + translation_placeholders={"media_id": media_id}, + ) + + @override + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """List the tuner presets as playable channels.""" + if media_content_id not in (None, TUNER_PRESETS_ROOT): + raise BrowseError(f"Media not found: {media_content_id}") + + return BrowseMedia( + title="Tuner presets", + media_class=MediaClass.DIRECTORY, + media_content_id=TUNER_PRESETS_ROOT, + media_content_type=MediaType.CHANNELS, + can_play=False, + can_expand=True, + children_media_class=MediaClass.CHANNEL, + children=[ + BrowseMedia( + title=preset, + media_class=MediaClass.CHANNEL, + media_content_id=preset, + media_content_type=MediaType.CHANNEL, + can_play=True, + can_expand=False, + ) + for preset in ( + f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9) + ) + ], + ) diff --git a/homeassistant/components/denon_rs232/strings.json b/homeassistant/components/denon_rs232/strings.json index 2ed91a0fb290..af70480b75eb 100644 --- a/homeassistant/components/denon_rs232/strings.json +++ b/homeassistant/components/denon_rs232/strings.json @@ -74,6 +74,14 @@ } } }, + "exceptions": { + "invalid_tuner_channel": { + "message": "{media_id} is not a valid tuner preset (A1-G8) or FM frequency in hundredths of MHz (8750-10800; for example, 9930 for 99.30 MHz)." + }, + "unsupported_media_type": { + "message": "Cannot play media of type {media_type}. Only tuner channels are supported." + } + }, "selector": { "model": { "options": { diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index ce593e5f8f8c..9814bb80b6d9 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index b2fa4bbb06f0..f7c5bfc32b5c 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -43,6 +43,32 @@ ENTITY_PLATFORMS = { } +def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: + """Resolve a device automation device id, following a composite device id. + + A device automation created when a device could be connected to more than one + config entry stores the id of the (now removed) composite device. When the + automation's domain owns one of the split devices' config entries, resolve to that + device - an integration may look the device up in its own registry, which only + knows the current device id, not the removed composite id. + """ + device_registry = dr.async_get(hass) + if device_id in device_registry.devices: + return device_id + if not ( + split_devices := device_registry.async_get_devices_for_composite_device_id( + device_id + ) + ): + return device_id + # Resolve to the device owned by a config entry of the automation's domain + for split_device in split_devices: + entry = hass.config_entries.async_get_entry(split_device.config_entry_id) + if entry is not None and entry.domain == domain: + return split_device.id + return device_id + + async def async_validate_device_automation_config( hass: HomeAssistant, config: ConfigType, @@ -51,6 +77,17 @@ async def async_validate_device_automation_config( ) -> ConfigType: """Validate config.""" validated_config: ConfigType = automation_schema(config) + + # A device automation may reference a pre-migration composite device id; resolve it + # to the split device for its domain so the device and its entities exist and the + # integration platform (validation and attach/call) receives a live device id + resolved_device_id = _resolve_device_id( + hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN] + ) + if resolved_device_id != validated_config[CONF_DEVICE_ID]: + config = {**config, CONF_DEVICE_ID: resolved_device_id} + validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id} + platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index c12320dcb16e..ffa8a1197fc6 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -707,17 +707,29 @@ class ScannerEntity( await super().async_internal_added_to_hass() return - # Attach entry to device - if self.registry_entry.device_id != device_entry.id: - self.registry_entry = er.async_get(self.hass).async_update_entity( - self.entity_id, device_id=device_entry.id + dev_reg = dr.async_get(self.hass) + # find_device_entry may return a synthesized pre-migration composite whose id is + # not a real device and can't be assigned to an entity; resolve it to the split + # owned by this config entry so we attach to a concrete device. + if device_entry.id not in dev_reg.devices: + device_entry = next( + ( + split + for split in dev_reg.async_get_devices_for_composite_device_id( + device_entry.id + ) + if split.config_entry_id == self.platform.config_entry.entry_id + ), + None, ) - # Attach device to config entry - if self.platform.config_entry.entry_id not in device_entry.config_entries: - dr.async_get(self.hass).async_update_device( - device_entry.id, - add_config_entry_id=self.platform.config_entry.entry_id, + # Attach entry to device + if ( + device_entry is not None + and self.registry_entry.device_id != device_entry.id + ): + self.registry_entry = er.async_get(self.hass).async_update_entity( + self.entity_id, device_id=device_entry.id ) # Do this last or else the entity registry update listener has been installed diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index f497b273bcc9..48f6077d5371 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -61,8 +61,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp from . import websocket_api from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .helpers import async_discovered_service_info from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers +__all__ = [ + "async_discovered_service_info", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) REGISTERED_DEVICES: Final = "registered_devices" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py index 7acf26f76fde..9c81fce1405c 100644 --- a/homeassistant/components/dhcp/helpers.py +++ b/homeassistant/components/dhcp/helpers.py @@ -4,7 +4,9 @@ from collections.abc import Callable from functools import partial from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .const import HOSTNAME, IP_ADDRESS from .models import DATA_DHCP, DHCPAddressData @@ -33,3 +35,16 @@ def async_get_address_data_internal( This is not intended for use by integrations. """ return hass.data[DATA_DHCP].address_data + + +@callback +def async_discovered_service_info(hass: HomeAssistant) -> list[DhcpServiceInfo]: + """Return the discovered DHCP devices.""" + return [ + DhcpServiceInfo( + ip=data[IP_ADDRESS], + hostname=data[HOSTNAME].lower(), + macaddress=mac_address, + ) + for mac_address, data in async_get_address_data_internal(hass).items() + ] diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 9d4b53093055..bca2cc73fd9e 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -36,9 +36,14 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType -from .util import async_redact_data, entity_entry_as_dict +from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict -__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"] +__all__ = [ + "REDACTED", + "async_redact_data", + "device_entry_as_dict", + "entity_entry_as_dict", +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 5dd6085e2df0..9326961c5d8c 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -6,6 +6,7 @@ from typing import Any, cast, overload import attr from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry from .const import REDACTED @@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: return cast(_T, redacted) +# DeviceEntry attributes that are internal bookkeeping and must not be exposed in +# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient +# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter. +# The composite-device migration attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + + +def _device_entry_filter(a: attr.Attribute, _: Any) -> bool: + return ( + not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ) + + +@callback +def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]: + """Convert a device registry entry to a dict for diagnostics. + + This excludes internal fields that should not be exposed in diagnostics. + """ + return attr.asdict(entry, filter=_device_entry_filter) + + def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: return a.name not in ( "_cache", diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 10cbf619c96e..e0755b153f09 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -10,8 +10,15 @@ from duco_connectivity.exceptions import ( DucoConnectionError, DucoError, DucoResponseError, + DucoUnsupportedCapabilityError, +) +from duco_connectivity.models import ( + BoardInfo, + Node, + NodeListActionItemList, + NodeName, + VentilationTemperatureInfo, ) -from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) type DucoConfigEntry = ConfigEntry[DucoCoordinator] -@dataclass +@dataclass(slots=True, kw_only=True) class DucoData: """Data returned by the Duco coordinator.""" @@ -34,6 +41,7 @@ class DucoData: node_actions: NodeListActionItemList rssi_wifi: int | None time_filter_remain: int | None + ventilation_temperatures: VentilationTemperatureInfo | None class DucoCoordinator(DataUpdateCoordinator[DucoData]): @@ -42,6 +50,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): config_entry: DucoConfigEntry board_info: BoardInfo _supports_time_filter_remain: bool + _supports_ventilation_temperatures: bool _configured_node_names: dict[int, str] def __init__( @@ -61,6 +70,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): self.client = client self._configured_node_names = {} self._supports_time_filter_remain = True + self._supports_ventilation_temperatures = True async def _async_load_node_names(self) -> None: """Load configured Duco node names during setup.""" @@ -175,9 +185,26 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): time_filter_remain = await self.client.async_get_time_filter_remaining() self._supports_time_filter_remain = time_filter_remain is not None + ventilation_temperatures = ( + self.data.ventilation_temperatures if self.data else None + ) + if self._supports_ventilation_temperatures: + try: + ventilation_temperatures = ( + await self.client.async_get_ventilation_temperature_info() + ) + except DucoUnsupportedCapabilityError: + ventilation_temperatures = None + self._supports_ventilation_temperatures = False + except DucoError as err: + _LOGGER.debug( + "Could not fetch Duco ventilation temperatures", exc_info=err + ) + return DucoData( nodes={node.node_id: node for node in nodes}, node_actions=node_actions, rssi_wifi=rssi_wifi, time_filter_remain=time_filter_remain, + ventilation_temperatures=ventilation_temperatures, ) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index f3806627f2a7..ee7222fe9c28 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.8.0"], + "requirements": ["python-duco-connectivity==0.10.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index faad3737c271..63e8f16cd27d 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfRatio, + UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback @@ -156,6 +157,70 @@ BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda coordinator: coordinator.data.rssi_wifi, ), + DucoBoxSensorEntityDescription( + key="outdoor_air_temperature", + translation_key="outdoor_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures is not None + and coordinator.data.ventilation_temperatures.temp_oda is not None + ), + value_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures.temp_oda + if coordinator.data.ventilation_temperatures + else None + ), + ), + DucoBoxSensorEntityDescription( + key="supply_air_temperature", + translation_key="supply_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures is not None + and coordinator.data.ventilation_temperatures.temp_sup is not None + ), + value_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures.temp_sup + if coordinator.data.ventilation_temperatures + else None + ), + ), + DucoBoxSensorEntityDescription( + key="extract_air_temperature", + translation_key="extract_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures is not None + and coordinator.data.ventilation_temperatures.temp_eta is not None + ), + value_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures.temp_eta + if coordinator.data.ventilation_temperatures + else None + ), + ), + DucoBoxSensorEntityDescription( + key="exhaust_air_temperature", + translation_key="exhaust_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures is not None + and coordinator.data.ventilation_temperatures.temp_eha is not None + ), + value_fn=lambda coordinator: ( + coordinator.data.ventilation_temperatures.temp_eha + if coordinator.data.ventilation_temperatures + else None + ), + ), ) diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index 2761903e336b..4f5eb782f93a 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -73,6 +73,12 @@ } }, "sensor": { + "exhaust_air_temperature": { + "name": "Exhaust air temperature" + }, + "extract_air_temperature": { + "name": "Extract air temperature" + }, "filter_remaining": { "name": "Filter remaining" }, @@ -82,6 +88,12 @@ "iaq_rh": { "name": "Humidity air quality index" }, + "outdoor_air_temperature": { + "name": "Outdoor air temperature" + }, + "supply_air_temperature": { + "name": "Supply air temperature" + }, "target_flow_level": { "name": "Target flow level" }, diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 7945f39aeb29..67818456dbe3 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry( """Set up a config entry.""" device_registry = dr.async_get(hass) if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): - device_registry.async_clear_config_entry(entry.entry_id) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) coordinator = DwdWeatherWarningsCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py index 8240e357cdcb..10cd8d2e481e 100644 --- a/homeassistant/components/edifier_infrared/button.py +++ b/homeassistant/components/edifier_infrared/button.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.infrared import InfraredEmitterConsumerEntity @@ -141,6 +142,48 @@ COMMAND_SET_BUTTONS: dict[ command_code=EdifierRC20GCode.COAX, ), ), + EdifierCommandSet.S3000PRO: ( + EdifierIrButtonEntityDescription( + key="usb", + translation_key="usb", + command_code=EdifierS3000ProCode.USB, + ), + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierS3000ProCode.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line_bal", + translation_key="line_bal", + command_code=EdifierS3000ProCode.LINE_BAL, + ), + EdifierIrButtonEntityDescription( + key="opt_coax", + translation_key="opt_coax", + command_code=EdifierS3000ProCode.OPT_COAX, + ), + EdifierIrButtonEntityDescription( + key="eq_monitor", + translation_key="eq_monitor", + command_code=EdifierS3000ProCode.EQ_MONITOR, + ), + EdifierIrButtonEntityDescription( + key="eq_dynamic", + translation_key="eq_dynamic", + command_code=EdifierS3000ProCode.EQ_DYNAMIC, + ), + EdifierIrButtonEntityDescription( + key="eq_classic", + translation_key="eq_classic", + command_code=EdifierS3000ProCode.EQ_CLASSIC, + ), + EdifierIrButtonEntityDescription( + key="eq_vocal", + translation_key="eq_vocal", + command_code=EdifierS3000ProCode.EQ_VOCAL, + ), + ), } diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py index 057f71a7c510..4fd4b959d7b2 100644 --- a/homeassistant/components/edifier_infrared/const.py +++ b/homeassistant/components/edifier_infrared/const.py @@ -5,6 +5,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode DOMAIN = "edifier_infrared" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" @@ -16,4 +17,5 @@ type EdifierCode = ( | EdifierR1280TCode | EdifierS360DBCode | EdifierRC20GCode + | EdifierS3000ProCode ) diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py index 6944e05b9156..8c9963e80e9d 100644 --- a/homeassistant/components/edifier_infrared/media_player.py +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.components.media_player import ( @@ -92,6 +93,19 @@ COMMAND_SET_COMMANDS: dict[ MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,), MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,), }, + EdifierCommandSet.S3000PRO: { + MediaPlayerEntityFeature.TURN_ON: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierS3000ProCode.VOLUME_UP,), + (EdifierS3000ProCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierS3000ProCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS3000ProCode.NEXT,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS3000ProCode.PREVIOUS,), + }, } diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json index 28235e17699b..0ed3c9e2c2a8 100644 --- a/homeassistant/components/edifier_infrared/strings.json +++ b/homeassistant/components/edifier_infrared/strings.json @@ -30,6 +30,18 @@ "coax": { "name": "Coaxial" }, + "eq_classic": { + "name": "Classic EQ" + }, + "eq_dynamic": { + "name": "Dynamic EQ" + }, + "eq_monitor": { + "name": "Monitor EQ" + }, + "eq_vocal": { + "name": "Vocal EQ" + }, "fx_off": { "name": "FX off" }, @@ -42,11 +54,20 @@ "line_2": { "name": "Line 2" }, + "line_bal": { + "name": "Line / Balanced" + }, + "opt_coax": { + "name": "Optical / Coaxial" + }, "optical": { "name": "Optical" }, "pc": { "name": "PC" + }, + "usb": { + "name": "USB" } } } diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 2e920cf6cadd..0e214728c82d 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, - DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) @@ -179,7 +178,7 @@ class EmbyDevice(MediaPlayerEntity): @override def name(self): """Return the name of the device.""" - return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME + return f"Emby {self.device.name}" @property @override diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index bc7ed9de5822..c551a3149eb8 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.14.1"] + "requirements": ["sense-energy==0.14.3"] } diff --git a/homeassistant/components/energieleser/__init__.py b/homeassistant/components/energieleser/__init__.py index f9167316eca0..5533dd6e91dc 100644 --- a/homeassistant/components/energieleser/__init__.py +++ b/homeassistant/components/energieleser/__init__.py @@ -4,8 +4,10 @@ from energieleser import EnergieleserClient from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN from .coordinator import EnergieleserConfigEntry, EnergieleserCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -30,4 +32,5 @@ async def async_unload_entry( hass: HomeAssistant, entry: EnergieleserConfigEntry ) -> bool: """Unload an energieleser config entry.""" + ir.async_delete_issue(hass, DOMAIN, f"pin_locked_{entry.entry_id}") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energieleser/coordinator.py b/homeassistant/components/energieleser/coordinator.py index e71e076425d5..6c026a69fa47 100755 --- a/homeassistant/components/energieleser/coordinator.py +++ b/homeassistant/components/energieleser/coordinator.py @@ -9,11 +9,13 @@ from energieleser import ( EnergieleserDevice, EnergieleserError, EnergieleserUnknownDeviceError, + StromleserOneDevice, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -74,4 +76,23 @@ class EnergieleserCoordinator(DataUpdateCoordinator[EnergieleserDevice]): "device_id": self.device_id, }, ) from err + if isinstance(device, StromleserOneDevice): + issue_id = f"pin_locked_{self.config_entry.entry_id}" + if device.pin_locked: + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + learn_more_url="https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation", + severity=ir.IssueSeverity.WARNING, + translation_key="meter_locked", + translation_placeholders={ + "device_name": self.config_entry.title, + }, + ) + else: + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + return device diff --git a/homeassistant/components/energieleser/diagnostics.py b/homeassistant/components/energieleser/diagnostics.py new file mode 100755 index 000000000000..792297e8c58b --- /dev/null +++ b/homeassistant/components/energieleser/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for energieleser.""" + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant + +from .coordinator import EnergieleserConfigEntry + +TO_REDACT = {CONF_DEVICE_ID, "fabrication_number"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: EnergieleserConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + device_data = coordinator.data + + device_data_dict = dataclasses.asdict(device_data) + + return async_redact_data(device_data_dict, TO_REDACT) diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 7173e0886296..ea4d7d13f731 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo @@ -69,7 +69,7 @@ rules: exception-translations: done icon-translations: todo reconfiguration-flow: done - repair-issues: todo + repair-issues: done stale-devices: status: exempt comment: One device per config entry; the device is removed when the entry is removed. diff --git a/homeassistant/components/energieleser/strings.json b/homeassistant/components/energieleser/strings.json index 7065aec00bc3..370ec96f4b9b 100755 --- a/homeassistant/components/energieleser/strings.json +++ b/homeassistant/components/energieleser/strings.json @@ -103,5 +103,11 @@ "unknown_device": { "message": "The device type for {device_id} is unknown or unsupported" } + }, + "issues": { + "meter_locked": { + "description": "The electricity meter connected to {device_name} is not providing high-resolution data. You need to unlock the physical meter by entering the PIN (provided by your electricity company or grid operator) directly on the meter. Once the meter is unlocked, high-resolution data will be provided and this issue will resolve itself automatically. See the linked instructions for details on how to enter the PIN.", + "title": "Meter PIN Required" + } } } diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 77d7c2a4dc97..7806ec781a23 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -5,11 +5,14 @@ from datetime import datetime from typing import TYPE_CHECKING, Any from aiohttp import ClientResponse -from attr import asdict from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context", None) entity_dict = entity_entry_as_dict(entity) entities.append({"entity": entity_dict, "state": state_dict}) - device_dict = asdict(device) - device_dict.pop("_cache", None) - # This can be removed when suggested_area is removed from DeviceEntry - device_dict.pop("_suggested_area") + device_dict = device_entry_as_dict(device) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5d329b61974f..b7c2eb8352e8 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,7 +2,7 @@ import logging -from aioesphomeapi import APIClient, APIConnectionError +from aioesphomeapi import APIConnectionError from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner @@ -11,13 +11,7 @@ from homeassistant.components.usb import ( USBDevice, async_register_serial_port_scanner, ) -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - EVENT_HOMEASSISTANT_STOP, - __version__ as ha_version, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import async_delete_issue @@ -29,15 +23,18 @@ from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData from .encryption_key_storage import async_get_encryption_key_storage from .entry_data import ESPHomeConfigEntry, RuntimeEntryData -from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance +from .manager import ( + DEVICE_CONFLICT_ISSUE_FORMAT, + ESPHomeManager, + async_create_api_client, + cleanup_instance, +) from .websocket_api import async_setup as async_setup_websocket_api _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CLIENT_INFO = f"Home Assistant {ha_version}" - @callback def _async_scan_serial_ports( @@ -90,20 +87,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool: """Set up the esphome component.""" host: str = entry.data[CONF_HOST] - port: int = entry.data[CONF_PORT] password: str | None = entry.data[CONF_PASSWORD] - noise_psk: str | None = entry.data.get(CONF_NOISE_PSK) zeroconf_instance = await zeroconf.async_get_instance(hass) - cli = APIClient( - host, - port, - password, - client_info=CLIENT_INFO, - zeroconf_instance=zeroconf_instance, - noise_psk=noise_psk, - timezone=hass.config.time_zone, + cli = async_create_api_client( + hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) ) domain_data = DomainData.get(hass) @@ -159,21 +148,10 @@ async def _async_clear_dynamic_encryption_key( if await storage.async_get_key(entry.unique_id) is None: return - host: str = entry.data[CONF_HOST] - port: int = entry.data[CONF_PORT] - password: str | None = entry.data[CONF_PASSWORD] - noise_psk: str | None = entry.data.get(CONF_NOISE_PSK) - zeroconf_instance = await zeroconf.async_get_instance(hass) - cli = APIClient( - host, - port, - password, - client_info=CLIENT_INFO, - zeroconf_instance=zeroconf_instance, - noise_psk=noise_psk, - timezone=hass.config.time_zone, + cli = async_create_api_client( + hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) ) try: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 71b99ced5fea..1d3488d3cd00 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -74,7 +74,11 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" ERROR_INVALID_PASSWORD_AUTH = "invalid_auth" _LOGGER = logging.getLogger(__name__) -ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +# A deliberately wrong key (base64 of thirty two ASCII zero characters, not +# zero bytes) used only to elicit the server hello so the device name can be +# read. Not to be confused with aioesphomeapi.ZERO_NOISE_PSK, the well known +# all zeros provisioning key. +PROBE_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" DEFAULT_NAME = "ESPHome" _BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector( @@ -271,7 +275,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # to get the device name which will allow us to populate # the device name and hopefully get the encryption key # from the dashboard. - self._noise_psk = ZERO_NOISE_PSK + self._noise_psk = PROBE_NOISE_PSK response = await self.fetch_device_info() self._noise_psk = None diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index b10995ac27cc..508065b091c8 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final from awesomeversion import AwesomeVersion from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.const import __version__ as ha_version from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -12,6 +13,8 @@ if TYPE_CHECKING: DOMAIN = "esphome" +CLIENT_INFO = f"Home Assistant {ha_version}" + ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN) CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a1428ddc702e..2f45d283e12b 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -9,6 +9,7 @@ import struct from typing import TYPE_CHECKING, Any, Final, NamedTuple from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -34,7 +35,10 @@ import voluptuous as vol from homeassistant.components import bluetooth, tag, zeroconf from homeassistant.const import ( ATTR_DEVICE_ID, + CONF_HOST, CONF_MODE, + CONF_PASSWORD, + CONF_PORT, EVENT_HOMEASSISTANT_CLOSE, EVENT_LOGGING_CHANGED, Platform, @@ -77,6 +81,7 @@ from homeassistant.util.json import json_loads_object from .bluetooth import async_connect_scanner from .const import ( + CLIENT_INFO, CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, @@ -101,6 +106,26 @@ DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" UNPACK_UINT32_BE = struct.Struct(">I").unpack_from +@callback +def async_create_api_client( + hass: HomeAssistant, + entry: ESPHomeConfigEntry, + zeroconf_instance: zeroconf.HaZeroconf, + *, + noise_psk: str | None, +) -> APIClient: + """Create an APIClient for a config entry.""" + return APIClient( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_PASSWORD], + client_info=CLIENT_INFO, + zeroconf_instance=zeroconf_instance, + noise_psk=noise_psk, + timezone=hass.config.time_zone, + ) + + if TYPE_CHECKING: from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001 @@ -812,6 +837,51 @@ class ESPHomeManager: if self.reconnect_logic: await self.reconnect_logic.stop() + async def _async_provision_key_over_noise(self, new_key: bytes) -> bool: + """Send the encryption key over a short lived zero PSK Noise connection. + + The well known all zeros PSK still runs a fresh ephemeral X25519 + exchange, so the key cannot be read by a passive listener on the + network. This protects against sniffing only; it does not + authenticate either side against an active man in the middle. + + Returns True if the device accepted the key. On failure the caller + simply returns; provisioning runs again on the next connect cycle. + """ + unique_id = self.entry.unique_id + cli = async_create_api_client( + self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK + ) + device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host) + try: + await cli.connect() + if await cli.noise_encryption_set_key(new_key): + return True + _LOGGER.error( + "Device %s (%s) rejected the encryption key", + device_name, + unique_id, + ) + except InvalidEncryptionKeyAPIError: + _LOGGER.error( + "Device %s (%s) rejected the zero PSK handshake; it appears " + "to already have an encryption key set", + device_name, + unique_id, + ) + except APIConnectionError as ex: + # Whatever went wrong, we never downgrade to a plaintext push; + # provisioning simply runs again on the next connect cycle + _LOGGER.error( + "Error provisioning encryption key for device %s (%s): %s", + device_name, + unique_id, + ex, + ) + finally: + await cli.disconnect(force=True) + return False + async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo ) -> None: @@ -853,18 +923,24 @@ class ESPHomeManager: new_key = base64.b64encode(secrets.token_bytes(32)) new_key_str = new_key.decode() - try: - # Store the key on the device using the existing connection - result = await self.cli.noise_encryption_set_key(new_key) - except APIConnectionError as ex: - _LOGGER.error( - "Connection error while storing encryption key for device %s (%s): %s", - self.entry.data.get(CONF_DEVICE_NAME, self.host), - self.entry.unique_id, - ex, - ) - return + if device_info.api_encryption_provisionable: + # New firmware: send the key over an encrypted zero PSK Noise + # connection so it cannot be sniffed off the network + if not await self._async_provision_key_over_noise(new_key): + return else: + # Old firmware only accepts the key over the existing plaintext + # connection. Deprecated; will be removed after the usual window. + try: + result = await self.cli.noise_encryption_set_key(new_key) + except APIConnectionError as ex: + _LOGGER.error( + "Connection error while storing encryption key for device %s (%s): %s", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ex, + ) + return if not result: _LOGGER.error( "Failed to set dynamic encryption key on device %s (%s)", @@ -977,6 +1053,10 @@ class ESPHomeManager: self._async_cleanup() if device_info.name: reconnect_logic.name = device_info.name + # Seed the backoff cap from the restored device_info so the first + # reconnect after a restart already caps for a deep-sleep device, + # before the first live connect refreshes it. + reconnect_logic.deep_sleep = device_info.has_deep_sleep if ( bluetooth_mac_address := device_info.bluetooth_mac_address ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7eb5b9744daf..d09e7d7f6481 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==45.6.0", + "aioesphomeapi==45.6.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.9.7" ], diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py index 8f84da1c1cf6..f3684930bef0 100644 --- a/homeassistant/components/firefly_iii/config_flow.py +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -15,7 +15,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -174,13 +173,13 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class FireflyClientTimeout(HomeAssistantError): +class FireflyClientTimeout(Exception): """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 684b5b3cbeb9..139db1a604fd 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -87,7 +87,7 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: domain=DOMAIN, issue_id="deprecated_cleanup_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_cleanup_button", translation_placeholders={"removal_version": "2026.11.0"}, @@ -114,7 +114,7 @@ def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) - domain=DOMAIN, issue_id="deprecated_firmware_update_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_firmware_update_button", translation_placeholders={"removal_version": "2026.11.0"}, diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index fcad98ef9f9e..aa043e825993 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -743,9 +743,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ): if not any(con in device.connections for con in valid_connections): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(device.id) fritz_data = self.hass.data[FRITZ_DATA_KEY] diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 496c04f2e055..1518ccfaa4df 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -121,9 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat ): if not set(device.identifiers) & identifiers: LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index db81df79958a..19724c285043 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.5"] + "requirements": ["home-assistant-frontend==20260624.6"] } diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 1a528e460bf4..e6506e6242de 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -11,6 +11,7 @@ from gardena_bluetooth.const import ( Battery, EventHistory, FlowStatistics, + Pump, Sensor, Spray, Valve, @@ -28,6 +29,8 @@ from homeassistant.const import ( DEGREE, PERCENTAGE, EntityCategory, + UnitOfPressure, + UnitOfTemperature, UnitOfVolume, UnitOfVolumeFlowRate, ) @@ -164,6 +167,24 @@ DESCRIPTIONS = ( char=FlowStatistics.last_reset, get=_get_timestamp, ), + GardenaBluetoothSensorEntityDescription( + key=Pump.tank_preassure.unique_id, + translation_key="tank_pressure", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.MBAR, + suggested_unit_of_measurement=UnitOfPressure.BAR, + suggested_display_precision=2, + char=Pump.tank_preassure, + ), + GardenaBluetoothSensorEntityDescription( + key=Pump.water_temperature.unique_id, + translation_key="water_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + char=Pump.water_temperature, + ), GardenaBluetoothSensorEntityDescription( key=Spray.current_distance.unique_id, translation_key="spray_current_distance", diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 797197beba8b..2ee29ffc7696 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -151,6 +151,12 @@ }, "spray_current_sector": { "name": "Current sector" + }, + "tank_pressure": { + "name": "Tank pressure" + }, + "water_temperature": { + "name": "Water temperature" } }, "switch": { diff --git a/homeassistant/components/gatus/__init__.py b/homeassistant/components/gatus/__init__.py new file mode 100644 index 000000000000..93cbcfc5999a --- /dev/null +++ b/homeassistant/components/gatus/__init__.py @@ -0,0 +1,25 @@ +"""The Gatus integration.""" + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Set up Gatus from a config entry.""" + coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL]) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/gatus/binary_sensor.py b/homeassistant/components/gatus/binary_sensor.py new file mode 100644 index 000000000000..f35d8815e42d --- /dev/null +++ b/homeassistant/components/gatus/binary_sensor.py @@ -0,0 +1,105 @@ +"""Support for Gatus binary sensors.""" + +from typing import override + +from gatus_api import EndpointStatus, Result + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GatusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Gatus binary sensor platform.""" + coordinator = entry.runtime_data + + async_add_entities( + GatusEndpointBinarySensor(coordinator, entry, endpoint_key) + for endpoint_key in coordinator.data + ) + + +class GatusEndpointBinarySensor( + CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Gatus endpoint status.""" + + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GatusDataUpdateCoordinator, + entry: GatusConfigEntry, + endpoint_key: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._endpoint_key = endpoint_key + + endpoint_data = self.endpoint_data + + endpoint_name = endpoint_data.name + if endpoint_data.group is not None: + device_name = f"{endpoint_data.group} {endpoint_name}" + else: + device_name = endpoint_name + + self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")}, + name=device_name, + manufacturer="Gatus", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + @override + def is_on(self) -> bool | None: + """Return true if the endpoint is up and healthy.""" + latest_result = self.latest_result + if latest_result is None: + return None + + return latest_result.success + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + data = self.coordinator.data + # Guard for empty results list, which could imply a brand new endpoint + return ( + super().available + and self._endpoint_key in data + and bool(data[self._endpoint_key].results) + ) + + @property + def endpoint_data(self) -> EndpointStatus: + """Return this specific endpoint's data from the coordinator.""" + return self.coordinator.data[self._endpoint_key] + + @property + def latest_result(self) -> Result | None: + """Return the most recent monitoring result (Gatus appends newest last).""" + results = self.endpoint_data.results + if not results: + return None + return results[-1] diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py new file mode 100644 index 000000000000..8abba8d95641 --- /dev/null +++ b/homeassistant/components/gatus/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for the Gatus integration.""" + +import logging +from typing import Any, override + +from gatus_api import GatusClient, GatusClientError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate that the user input allows us to connect to Gatus and return data.""" + client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass)) + + try: + await client.get_endpoints_statuses() + except GatusClientError as err: + _LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err) + raise CannotConnect from err + + +class GatusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gatus.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step when adding the integration via the UI.""" + errors: dict[str, str] = {} + + if user_input is not None: + user_input[CONF_URL] = str( + URL(user_input[CONF_URL]) + .with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Gatus", data=user_input) + + 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_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + user_input[CONF_URL] = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + if user_input[CONF_URL] != reconfigure_entry.data[CONF_URL]: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus reconfigure") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/const.py b/homeassistant/components/gatus/const.py new file mode 100644 index 000000000000..89ac9ee41fff --- /dev/null +++ b/homeassistant/components/gatus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Gatus integration.""" + +DOMAIN = "gatus" diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py new file mode 100644 index 000000000000..37739f2ff6f3 --- /dev/null +++ b/homeassistant/components/gatus/coordinator.py @@ -0,0 +1,48 @@ +"""DataUpdateCoordinator for the Gatus integration.""" + +from datetime import timedelta +import logging +from typing import override + +from gatus_api import EndpointStatus, GatusClient, GatusClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator] + + +class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]): + """Class to manage fetching Gatus data from the API via third-party library.""" + + def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None: + """Initialize the coordinator.""" + self.url = url.rstrip("/") + self.client = GatusClient(url=self.url, session=async_get_clientsession(hass)) + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @override + async def _async_update_data(self) -> dict[str, EndpointStatus]: + """Fetch endpoint statuses from the Gatus API.""" + try: + raw_endpoints = await self.client.get_endpoints_statuses() + except GatusClientError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err + + return {ep.key: ep for ep in raw_endpoints} diff --git a/homeassistant/components/gatus/diagnostics.py b/homeassistant/components/gatus/diagnostics.py new file mode 100644 index 000000000000..eff962bc5cae --- /dev/null +++ b/homeassistant/components/gatus/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for Gatus.""" + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: GatusConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return { + "data": [ + { + "key": ep.key, + "name": ep.name, + "group": ep.group, + "results": [ + { + "success": r.success, + "status": r.status, + } + for r in ep.results + ], + } + for ep in coordinator.data.values() + ], + } diff --git a/homeassistant/components/gatus/manifest.json b/homeassistant/components/gatus/manifest.json new file mode 100644 index 000000000000..53fddeab56ed --- /dev/null +++ b/homeassistant/components/gatus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gatus", + "name": "Gatus", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gatus", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["gatus_api"], + "quality_scale": "silver", + "requirements": ["gatus-api==1.0.3"] +} diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml new file mode 100644 index 000000000000..2024f5508fd8 --- /dev/null +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not register custom events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Integration does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities represent monitored services and should be enabled by default. + entity-translations: + status: exempt + comment: Entity names are dynamically provided by the Gatus service. + exception-translations: done + icon-translations: + status: exempt + comment: Entities use the connectivity device class for their icon and define no custom icons. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: Integration does not require user intervention repairs. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json new file mode 100644 index 000000000000..413dc7180c91 --- /dev/null +++ b/homeassistant/components/gatus/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::gatus::config::step::user::data_description::url%]" + }, + "description": "[%key:component::gatus::config::step::user::description%]" + }, + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The full base URL of your Gatus status page instance including protocol and port." + }, + "description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port." + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error communicating with Gatus API" + } + } +} diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 9af17b89c1ce..9540869b2765 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -105,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # humidifier's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index e2e997b9c11b..75f552b2850a 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -33,7 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # heater's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 788a5dffac79..8d7c3b24cc42 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from typing import override from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -92,8 +92,8 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) @override async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index 4d2dc968eb9c..4b4bb22d6d9c 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -98,9 +98,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> if device := dev_reg.async_get_device({(DOMAIN, repository)}): dev_reg.async_update_device( device.id, - remove_config_entry_id=entry.entry_id, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=entry.entry_id, + new_config_entry_id=entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) hass.config_entries.async_update_entry(entry, minor_version=2) return True diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index b3f0eb0ce829..2667a278c9c1 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( DEFAULT_AI_TASK_NAME, @@ -182,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -192,20 +192,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/google_health/coordinator.py b/homeassistant/components/google_health/coordinator.py index 680bccb0e1b6..a9b515b998aa 100644 --- a/homeassistant/components/google_health/coordinator.py +++ b/homeassistant/components/google_health/coordinator.py @@ -1,5 +1,6 @@ """Coordinators for Google Health.""" +import asyncio from dataclasses import dataclass from datetime import timedelta import logging @@ -12,9 +13,13 @@ from google_health_api.exceptions import ( HealthAuthException, ) from google_health_api.model import ( + ActiveEnergyBurnedRollupValue, + BodyFat, DailyRestingHeartRate, DistanceRollupValue, + FloorsRollupValue, StepsRollupValue, + TotalCaloriesRollupValue, Weight, ) @@ -40,6 +45,9 @@ class GoogleHealthActivityData: steps: StepsRollupValue | None = None distance: DistanceRollupValue | None = None + active_energy_burned: ActiveEnergyBurnedRollupValue | None = None + total_calories: TotalCaloriesRollupValue | None = None + floors: FloorsRollupValue | None = None @dataclass @@ -48,6 +56,7 @@ class GoogleHealthBodyData: weight: Weight | None = None resting_heart_rate: DailyRestingHeartRate | None = None + body_fat: BodyFat | None = None class GoogleHealthDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -116,20 +125,42 @@ class GoogleHealthActivityCoordinator( @override async def _async_fetch_data(self) -> GoogleHealthActivityData: - """Fetch steps and distance rollup for today. + """Fetch activity rollups for today. - Queries the daily rollup endpoints using Home Assistant's local time zone - to aggregate step and distance counts over the current civil day. If no - data points exist for today yet, the API returns None, which the sensors - default to 0. + Queries the daily rollup endpoints in parallel using Home Assistant's + local time zone to aggregate steps, distance, active calories, total + calories, and floors. If no data points exist for today yet, the API + returns None, which the sensors default to 0. """ - steps_rollup = await self.api.steps.today(self.hass.config.time_zone) - distance_rollup = await self.api.distance.today(self.hass.config.time_zone) + ( + steps_rollup, + distance_rollup, + active_energy_rollup, + total_calories_rollup, + floors_rollup, + ) = await asyncio.gather( + self.api.steps.today(self.hass.config.time_zone), + self.api.distance.today(self.hass.config.time_zone), + self.api.active_energy_burned.today(self.hass.config.time_zone), + self.api.total_calories.today(self.hass.config.time_zone), + self.api.floors.today(self.hass.config.time_zone), + ) steps = steps_rollup.data if steps_rollup else None distance = distance_rollup.data if distance_rollup else None + active_energy_burned = ( + active_energy_rollup.data if active_energy_rollup else None + ) + total_calories = total_calories_rollup.data if total_calories_rollup else None + floors = floors_rollup.data if floors_rollup else None - return GoogleHealthActivityData(steps=steps, distance=distance) + return GoogleHealthActivityData( + steps=steps, + distance=distance, + active_energy_burned=active_energy_burned, + total_calories=total_calories, + floors=floors, + ) class GoogleHealthBodyCoordinator( @@ -155,13 +186,14 @@ class GoogleHealthBodyCoordinator( @override async def _async_fetch_data(self) -> GoogleHealthBodyData: - """Fetch latest body weight and resting heart rate.""" + """Fetch latest body weight, resting heart rate, and body fat in parallel.""" # The Google Health API returns data points sorted by interval start time # in descending order (newest first). Querying with page_size=1 and grabbing # the first element is sufficient to fetch the most recent measurement. - weight_result = await self.api.weight.list(page_size=DEFAULT_PAGE_SIZE) - hr_result = await self.api.daily_resting_heart_rate.list( - page_size=DEFAULT_PAGE_SIZE + weight_result, hr_result, body_fat_result = await asyncio.gather( + self.api.weight.list(page_size=DEFAULT_PAGE_SIZE), + self.api.daily_resting_heart_rate.list(page_size=DEFAULT_PAGE_SIZE), + self.api.body_fat.list(page_size=DEFAULT_PAGE_SIZE), ) weight = ( @@ -170,7 +202,12 @@ class GoogleHealthBodyCoordinator( resting_heart_rate = ( hr_result.data_points[0].data if hr_result.data_points else None ) + body_fat = ( + body_fat_result.data_points[0].data if body_fat_result.data_points else None + ) return GoogleHealthBodyData( - weight=weight, resting_heart_rate=resting_heart_rate + weight=weight, + resting_heart_rate=resting_heart_rate, + body_fat=body_fat, ) diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py index f841058115d4..004f84bce3de 100644 --- a/homeassistant/components/google_health/sensor.py +++ b/homeassistant/components/google_health/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfMass +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -56,6 +56,32 @@ ACTIVITY_SENSORS: list[ data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0 ), ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="active_calories", + translation_key="active_calories", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.active_energy_burned.kcal_sum + if data and data.active_energy_burned + else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="total_calories", + translation_key="total_calories", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.total_calories.kcal_sum if data and data.total_calories else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int]( + key="floors", + translation_key="floors", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.floors.count_sum if data and data.floors else 0, + ), ] BODY_SENSORS: list[ @@ -81,6 +107,15 @@ BODY_SENSORS: list[ else None ), ), + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None]( + key="body_fat", + translation_key="body_fat", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.body_fat.percentage if data and data.body_fat else None + ), + ), ] diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index 8159cafe0444..3263e03978f1 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -35,12 +35,24 @@ }, "entity": { "sensor": { + "active_calories": { + "name": "Active calories" + }, + "body_fat": { + "name": "Body fat" + }, + "floors": { + "name": "Floors" + }, "resting_heart_rate": { "name": "Resting heart rate" }, "steps": { "name": "Steps", "unit_of_measurement": "steps" + }, + "total_calories": { + "name": "Total calories" } } }, diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index cb2aaf8b18b2..c3ae0c0382cf 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( @@ -57,7 +57,7 @@ from .schemas import ( UNITS_SELECTOR, ) -RECONFIGURE_SCHEMA = vol.Schema( +CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_DESTINATION): cv.string, @@ -65,14 +65,6 @@ RECONFIGURE_SCHEMA = vol.Schema( } ) -CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend( - { - # Name field is no longer allowed in config flow schemas - # pylint: disable-next=home-assistant-config-flow-name-field - vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_LANGUAGE): LANGUAGE_SELECTOR, @@ -184,7 +176,7 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): errors = await validate_input(self.hass, user_input) if not errors: return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), + title=DEFAULT_NAME, data=user_input, options=default_options(self.hass), ) @@ -210,7 +202,7 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - RECONFIGURE_SCHEMA, self._get_reconfigure_entry().data + CONFIG_SCHEMA, self._get_reconfigure_entry().data ), errors=errors, ) diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index ab1b01b8a640..3697998e262b 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, - CONF_NAME, EVENT_HOMEASSISTANT_STARTED, UnitOfTime, ) @@ -74,7 +73,7 @@ async def async_setup_entry( api_key = config_entry.data[CONF_API_KEY] origin = config_entry.data[CONF_ORIGIN] destination = config_entry.data[CONF_DESTINATION] - name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + name = config_entry.title client_options = ClientOptions(api_key=api_key) client = RoutesAsyncClient(client_options=client_options) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 277f6e20b913..ea01c8f56d51 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -23,7 +23,6 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "destination": "Destination", - "name": "[%key:common::config_flow::data::name%]", "origin": "Origin" }, "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)" diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index c8dd60ba98f2..32e591e099cd 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -2,13 +2,11 @@ from typing import override -from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, ) +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -120,9 +118,11 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_GPS_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY), diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 4435217a04ad..abf9118c8fa5 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -453,10 +453,7 @@ async def async_setup_entry( for device_sn in device_domain_ids: if coordinator := runtime_data.devices.pop(device_sn, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[GrowattCoordinator] = [] diff --git a/homeassistant/components/harbor/__init__.py b/homeassistant/components/harbor/__init__.py new file mode 100644 index 000000000000..1f10679688d0 --- /dev/null +++ b/homeassistant/components/harbor/__init__.py @@ -0,0 +1,43 @@ +"""The Harbor integration.""" + +from harbor.config import HarborCameraConfig + +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS +from .coordinator import HarborConfigEntry, HarborCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Set up Harbor from a config entry.""" + coordinator = HarborCoordinator( + hass, + entry, + HarborCameraConfig( + serial=entry.data[CONF_SERIAL], + cert_pem=entry.data[CONF_CERT_PEM], + key_pem=entry.data[CONF_KEY_PEM], + ip_address=entry.data[CONF_IP_ADDRESS], + ), + ) + await coordinator.async_start() + try: + await coordinator.async_wait_until_ready() + except TimeoutError as err: + await coordinator.async_shutdown() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Unload a Harbor config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.async_shutdown() + return unload_ok diff --git a/homeassistant/components/harbor/config_flow.py b/homeassistant/components/harbor/config_flow.py new file mode 100644 index 000000000000..05d222fea54c --- /dev/null +++ b/homeassistant/components/harbor/config_flow.py @@ -0,0 +1,128 @@ +"""Config flow for Harbor.""" + +from typing import Any, override + +from harbor.config import HarborCameraConfig +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers import selector + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN +from .coordinator import async_probe_camera + +SERIAL_LENGTH = 10 + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()), + vol.Required(CONF_CERT_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_KEY_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_IP_ADDRESS): selector.TextSelector( + selector.TextSelectorConfig() + ), + } +) + + +def _validate_serial(value: str) -> bool: + """Validate the Harbor serial number.""" + return len(value) == SERIAL_LENGTH and value.isdigit() + + +def _validate_cert_pem(value: str) -> bool: + """Validate a Harbor client certificate PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith( + "-----END CERTIFICATE-----" + ) + + +def _validate_key_pem(value: str) -> bool: + """Validate a Harbor private key PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith( + "-----END PRIVATE KEY-----" + ) + + +def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]: + """Validate cert/key PEM blobs and return any errors.""" + errors: dict[str, str] = {} + if not _validate_cert_pem(cert_pem): + errors[CONF_CERT_PEM] = "invalid_cert" + if not _validate_key_pem(key_pem): + errors[CONF_KEY_PEM] = "invalid_key" + return errors + + +class HarborConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Harbor.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors={}, + ) + + normalized = { + key: value.strip() if isinstance(value, str) else value + for key, value in user_input.items() + } + errors: dict[str, str] = {} + display_name: str | None = None + + serial = normalized[CONF_SERIAL] + if not _validate_serial(serial): + errors[CONF_SERIAL] = "invalid_serial" + + errors.update( + _validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM]) + ) + + if not errors: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + config = HarborCameraConfig( + serial=serial, + cert_pem=normalized[CONF_CERT_PEM], + key_pem=normalized[CONF_KEY_PEM], + ip_address=normalized[CONF_IP_ADDRESS], + ) + try: + display_name = await async_probe_camera(config) + except TimeoutError: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + ) + + entry_data: dict[str, Any] = { + CONF_SERIAL: serial, + CONF_CERT_PEM: normalized[CONF_CERT_PEM], + CONF_KEY_PEM: normalized[CONF_KEY_PEM], + CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS], + } + + return self.async_create_entry( + title=display_name or f"Camera {serial}", + data=entry_data, + ) diff --git a/homeassistant/components/harbor/const.py b/homeassistant/components/harbor/const.py new file mode 100644 index 000000000000..f9b5670e332b --- /dev/null +++ b/homeassistant/components/harbor/const.py @@ -0,0 +1,13 @@ +"""Constants for the Harbor integration.""" + +from homeassistant.const import Platform + +DOMAIN = "harbor" +MANUFACTURER = "Harbor" +MODEL = "Harbor Camera" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_CERT_PEM = "cert_pem" +CONF_KEY_PEM = "key_pem" +CONF_SERIAL = "serial" diff --git a/homeassistant/components/harbor/coordinator.py b/homeassistant/components/harbor/coordinator.py new file mode 100644 index 000000000000..55afb751b663 --- /dev/null +++ b/homeassistant/components/harbor/coordinator.py @@ -0,0 +1,176 @@ +"""Coordinator for Harbor.""" + +import asyncio +import logging +from typing import Any, override +from uuid import uuid4 + +from harbor.config import HarborCameraConfig +from harbor.devices.camera import HarborCamera +from harbor.mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient +from harbor.state import HarborDeviceState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import instance_id +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, MODEL + +LOGGER = logging.getLogger(__name__) + +type HarborConfigEntry = ConfigEntry[HarborCoordinator] + +# How long to wait for the first successful MQTT connection and the first +# device data to arrive before treating the camera as unreachable, both when +# validating the config flow and during setup. +CONNECT_TIMEOUT = 30.0 + + +async def _discard_message(topic: str, payload: Any) -> None: + """Ignore messages received while probing the connection.""" + + +async def async_probe_camera(config: HarborCameraConfig) -> str | None: + """Connect to a Harbor camera and return its friendly name, if any. + + Raises ``TimeoutError`` when no MQTT session can be established with the + camera. Returns the camera's configured display name, or ``None`` when the + camera is reachable but has no name (or does not answer the settings + request in time). + """ + connected = asyncio.Event() + + async def _on_connection_change(is_connected: bool) -> None: + if is_connected: + connected.set() + + client = HarborMQTTClient( + config=config, + # Subscribe to the responses topic so the get-settings reply can be + # matched to its pending request; without a subscription the reply + # never reaches the client and the request would time out. + topics=[f"cameras/{config.serial}/responses/#"], + message_handler=_discard_message, + client_id=f"{DOMAIN}-{config.serial}-probe-{uuid4().hex[:8]}", + on_connection_change=_on_connection_change, + connection_grace_period=0, + ) + await client.start() + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await connected.wait() + try: + settings = await client.get_settings() + except TimeoutError, ConnectionError: + return None + if settings.settings is None: + return None + return settings.settings.preference_display_name + finally: + await client.stop() + + +class HarborCoordinator(DataUpdateCoordinator[HarborDeviceState]): + """Own the MQTT transport and state for a single Harbor camera.""" + + config_entry: HarborConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: HarborConfigEntry, + config: HarborCameraConfig, + ) -> None: + """Initialize the Harbor coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{config.serial}", + ) + self._config = config + self.device = HarborCamera(config) + self.data = self.device.state + self.connected = False + self._ssl_context_cache: dict[str, Any] = {} + self._mqtt_client: HarborMQTTClient | None = None + self._connected_event = asyncio.Event() + self._data_event = asyncio.Event() + self._unsubscribe_updates = self.device.subscribe_updates( + self._handle_device_update + ) + + async def async_start(self) -> None: + """Start the Harbor MQTT client.""" + hass_instance_id = await instance_id.async_get(self.hass) + client_id = ( + f"{DOMAIN}-{hass_instance_id[:8]}-" + f"{self.config_entry.entry_id[:8]}-{self._config.serial}" + ) + self._mqtt_client = HarborMQTTClient( + config=self._config, + topics=self.device.get_topics(), + message_handler=self.device.handle_message, + client_id=client_id, + ssl_context_cache=self._ssl_context_cache, + on_connection_change=self._async_set_connected, + # Fetch the full settings snapshot on every (re)connection so the + # device name and settings-derived state populate immediately + # instead of waiting for the next heartbeat. + initial_commands=DEFAULT_INITIAL_COMMANDS, + ) + await self._mqtt_client.start() + + async def async_wait_until_ready(self) -> None: + """Wait for the first MQTT connection and the first device data. + + Registering entities only once the camera's first message has + arrived means the device registry sees the real name and firmware + from the start, instead of a placeholder that would otherwise + persist until the next reload. + + Raises ``TimeoutError`` if the camera does not connect and report + data in time. + """ + async with asyncio.timeout(CONNECT_TIMEOUT): + await self._connected_event.wait() + await self._data_event.wait() + + @override + async def async_shutdown(self) -> None: + """Stop the MQTT client and release device resources.""" + await super().async_shutdown() + if self._mqtt_client is not None: + await self._mqtt_client.stop() + self._mqtt_client = None + self._unsubscribe_updates() + self.device.shutdown() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the Harbor camera.""" + state = self.data + return DeviceInfo( + identifiers={(DOMAIN, state.serial)}, + manufacturer=MANUFACTURER, + model=MODEL, + name=state.display_name or f"{MODEL} {state.serial}", + serial_number=state.serial, + sw_version=state.os_version, + ) + + def _handle_device_update(self, state: HarborDeviceState) -> None: + """Mirror a library device update into Home Assistant.""" + self._data_event.set() + self.async_set_updated_data(state) + + async def _async_set_connected(self, connected: bool) -> None: + """Propagate the MQTT connection state to entity availability.""" + if connected: + self._connected_event.set() + if self.connected == connected: + return + self.connected = connected + self.async_update_listeners() diff --git a/homeassistant/components/harbor/entity.py b/homeassistant/components/harbor/entity.py new file mode 100644 index 000000000000..b04a3b3269bf --- /dev/null +++ b/homeassistant/components/harbor/entity.py @@ -0,0 +1,37 @@ +"""Base entities for Harbor.""" + +from typing import override + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HarborCoordinator + + +class HarborEntity(CoordinatorEntity[HarborCoordinator]): + """Base Harbor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HarborCoordinator, + unique_key: str, + ) -> None: + """Initialize the Harbor entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.serial}_{unique_key}" + + @override + @property + def available(self) -> bool: + """Return if the entity is currently available.""" + if not self.coordinator.connected: + return False + return self.coordinator.data.last_seen is not None + + @override + @property + def device_info(self) -> DeviceInfo: + """Return the device info for the backing Harbor device.""" + return self.coordinator.device_info diff --git a/homeassistant/components/harbor/icons.json b/homeassistant/components/harbor/icons.json new file mode 100644 index 000000000000..50c18c3b3a7e --- /dev/null +++ b/homeassistant/components/harbor/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "num_viewers": { + "default": "mdi:account-eye" + }, + "stream_quality": { + "default": "mdi:signal" + }, + "wifi_strength": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/harbor/manifest.json b/homeassistant/components/harbor/manifest.json new file mode 100644 index 000000000000..a9f927b12828 --- /dev/null +++ b/homeassistant/components/harbor/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "harbor", + "name": "Harbor Sleep", + "codeowners": ["@Lash-L", "@afgarcia86"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/harbor", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["harbor"], + "quality_scale": "bronze", + "requirements": ["harbor-python==1.2.1"] +} diff --git a/homeassistant/components/harbor/quality_scale.yaml b/homeassistant/components/harbor/quality_scale.yaml new file mode 100644 index 000000000000..9fb9660e4441 --- /dev/null +++ b/homeassistant/components/harbor/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: This integration is push-based via MQTT and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities receive updates via the coordinator and do not subscribe to events directly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/harbor/sensor.py b/homeassistant/components/harbor/sensor.py new file mode 100644 index 000000000000..ee1d03260895 --- /dev/null +++ b/homeassistant/components/harbor/sensor.py @@ -0,0 +1,97 @@ +"""Sensor entities for Harbor.""" + +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HarborConfigEntry, HarborCoordinator +from .entity import HarborEntity + +PARALLEL_UPDATES = 0 + +CAMERA_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="num_viewers", + translation_key="num_viewers", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="bitrate", + translation_key="bitrate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="wifi_strength", + translation_key="wifi_strength", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="stream_quality", + translation_key="stream_quality", + device_class=SensorDeviceClass.ENUM, + options=["excellent", "fair", "good", "poor"], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarborConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Harbor sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HarborSensor(coordinator, description) for description in CAMERA_SENSORS + ) + + +class HarborSensor(HarborEntity, SensorEntity): + """A Harbor sensor entity.""" + + def __init__( + self, + coordinator: HarborCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Harbor sensor.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @override + @property + def native_value(self) -> StateType: + """Return the current sensor value.""" + value = self.coordinator.data.values.get(self.entity_description.key) + if ( + self.entity_description.device_class == SensorDeviceClass.ENUM + and value == "unknown" + ): + # The library falls back to the literal string "unknown" for any + # enum value it doesn't recognize; surface that as no value + # rather than a bogus member of the options list. + return None + return value diff --git a/homeassistant/components/harbor/strings.json b/homeassistant/components/harbor/strings.json new file mode 100644 index 000000000000..1d4c0bae7c10 --- /dev/null +++ b/homeassistant/components/harbor/strings.json @@ -0,0 +1,59 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_cert": "The client certificate must be a valid PEM certificate", + "invalid_key": "The private key must be a valid PEM private key", + "invalid_serial": "The serial number must be exactly 10 digits" + }, + "step": { + "user": { + "data": { + "cert_pem": "Client certificate", + "ip_address": "[%key:common::config_flow::data::ip%]", + "key_pem": "Private key", + "serial": "Serial number" + }, + "data_description": { + "cert_pem": "Paste the client certificate from the Harbor app.", + "ip_address": "The local IP address of the Harbor device.", + "key_pem": "Paste the private key that matches the client certificate.", + "serial": "The 10-digit serial number printed on the Harbor device." + }, + "title": "Set up Harbor" + } + } + }, + "entity": { + "sensor": { + "bitrate": { + "name": "Bitrate" + }, + "num_viewers": { + "name": "Viewers", + "unit_of_measurement": "viewers" + }, + "stream_quality": { + "name": "Stream quality", + "state": { + "excellent": "Excellent", + "fair": "Fair", + "good": "Good", + "poor": "Poor" + } + }, + "wifi_strength": { + "name": "Wi-Fi strength", + "unit_of_measurement": "bars" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to the Harbor camera. It may be offline or unreachable." + } + } +} diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 191d3b6e2338..0dd9e2da0187 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -1411,6 +1411,11 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) + async def async_refresh_after_store_reload(self) -> None: + """Refresh addon data when the store was already reloaded externally.""" + async with self._debounced_refresh.async_lock(): + await super()._async_refresh(log_failures=True) + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: """Force refresh of addon info data for a specific addon.""" try: diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index a3166d15888d..dc45e57ea2fb 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -53,7 +54,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity_entry), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "coordinator_data": coordinator.data.to_dict(), diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index ed3034437e1f..dea7dbfbd45a 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import ( from .config import HassioUpdateParametersDict from .const import ( + ADDONS_COORDINATOR, ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, @@ -59,6 +60,10 @@ 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__) @@ -159,6 +164,15 @@ 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/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 119e13d826c6..545fb040c524 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -21,13 +21,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, -) +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( @@ -88,11 +82,6 @@ def get_user_step_schema(data: Mapping[str, Any]) -> vol.Schema: travel_mode = TRAVEL_MODE_PUBLIC return vol.Schema( { - # Name field is no longer allowed in config flow schemas - # pylint: disable-next=home-assistant-config-flow-name-field - vol.Optional( - CONF_NAME, default=data.get(CONF_NAME, DEFAULT_NAME) - ): cv.string, vol.Required(CONF_API_KEY, default=data.get(CONF_API_KEY)): cv.string, vol.Optional( CONF_MODE, default=data.get(CONF_MODE, TRAVEL_MODE_CAR) @@ -136,7 +125,6 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" if not errors: - self._config[CONF_NAME] = user_input[CONF_NAME] self._config[CONF_API_KEY] = user_input[CONF_API_KEY] self._config[CONF_MODE] = user_input[CONF_MODE] return await self.async_step_origin_menu() @@ -237,11 +225,10 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_RECONFIGURE: return self.async_update_reload_and_abort( self._get_reconfigure_entry(), - title=self._config[CONF_NAME], data=self._config, ) return self.async_create_entry( - title=self._config[CONF_NAME], + title=DEFAULT_NAME, data=self._config, options=DEFAULT_OPTIONS, ) @@ -283,7 +270,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): self._get_reconfigure_entry(), data=self._config ) return self.async_create_entry( - title=self._config[CONF_NAME], + title=DEFAULT_NAME, data=self._config, options=DEFAULT_OPTIONS, ) diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 19e8dfe88a19..e975434710ac 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONF_MODE, - CONF_NAME, EntityStateAttribute, UnitOfLength, UnitOfTime, @@ -83,7 +82,7 @@ async def async_setup_entry( """Add HERE travel time entities from a config_entry.""" entry_id = config_entry.entry_id - name = config_entry.data[CONF_NAME] + name = config_entry.title coordinator = config_entry.runtime_data sensors: list[HERETravelTimeSensor] = [ diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index a4d30e2b11a6..6bd36113481a 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -50,8 +50,7 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "mode": "Travel mode", - "name": "[%key:common::config_flow::data::name%]" + "mode": "Travel mode" } } } diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index ebfb13653254..35745d6ebfbb 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -78,7 +78,6 @@ async def async_setup_entry( entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 44e475995ffb..c65e96298e01 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -93,9 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) for device in device_entries: if not device.identifiers.intersection(appliances_identifiers): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 01a7d3995344..dac324ea09ac 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -105,10 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo ) if not is_node_present: _LOGGER.info("Removing device %s", device.name) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) # Remove device at runtime when node is removed in homee async def _remove_node_callback(node: HomeeNode, add: bool) -> None: diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 3309e8931531..41baaf1a5823 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -152,10 +152,8 @@ def remove_stale_devices( # If device_id is None an invalid device entry was # found for this config entry. If the device_id is not # in existing device ids it's a stale device entry. - # Remove config entry from this device entry in either case. - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + # Remove the device entry in either case. + device_registry.async_remove_device(device_entry.id) class HoneywellUSThermostat(ClimateEntity): diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 4474afcb0cd5..04b622df32bb 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -59,7 +59,14 @@ from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans -from .config import async_load_config, default_server_port +from .config import ( + _DEFAULT_CONFIG, + ConfData, + HTTPConfigStore, + async_get_and_load_store, + async_load_config, + default_server_port, +) from .const import ( # noqa: F401 CONF_BASE_URL, CONF_CORS_ORIGINS, @@ -89,7 +96,7 @@ from .headers import setup_headers from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource -from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite +from .web_runner import HomeAssistantUnixSite _LOGGER: Final = logging.getLogger(__name__) @@ -167,6 +174,63 @@ class ApiConfig: self.use_ssl = use_ssl +async def _async_fallback_config( + hass: HomeAssistant, + store: HTTPConfigStore, + conf: ConfData, + err: HomeAssistantError | OSError, +) -> ConfData: + """Return the next config to try after ``conf`` could not be applied. + + Implements the fallback chain pending -> stable -> default config, where + the last step is only taken in recovery mode. Raises when there is no + (acceptable) fallback left, failing setup: on a normal boot this + activates recovery mode, in recovery mode it makes the failure visible + to the outside (e.g. the Supervisor rolls back a Core update whose API + does not come up). + """ + if store.revert_deadline is not None: + # An unconfirmed pending config is under trial and cannot even be + # applied, so it is known to be bad: revert to the stable config + # right away and continue this same start with it, instead of + # waiting out the trial window and restarting. + _LOGGER.error( + "The new HTTP configuration could not be applied, reverting to " + "the previous configuration: %s", + err, + ) + await store.async_abort_trial() + return store.stable + + if ( + # In normal mode, fail setup so recovery mode can take over with a + # reachable configuration. + not hass.config.recovery_mode + # The chain is exhausted; nothing left to fall back to. + or conf is _DEFAULT_CONFIG + # With peer certificate verification configured, connections must + # never be accepted without a verified client certificate; there is + # no acceptable fallback config. + or CONF_SSL_PEER_CERTIFICATE in conf + ): + # An unusable SSL configuration already carries a descriptive + # HomeAssistantError. + if isinstance(err, HomeAssistantError): + raise err + raise HomeAssistantError( + f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" + ) from err + + # The config cannot be applied in recovery mode; fall back to the + # default config so the recovery UI stays reachable. + _LOGGER.error( + "The HTTP configuration could not be applied in recovery mode, " + "falling back to the default configuration: %s", + err, + ) + return _DEFAULT_CONFIG + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" # Late import to ensure isal is updated before @@ -187,6 +251,67 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api_module.async_register_websocket_commands(hass) + supervisor_unix_socket_path: Path | None = None + if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): + socket_path = Path(socket_env) + if socket_path.is_absolute(): + supervisor_unix_socket_path = socket_path + else: + _LOGGER.error( + "Invalid Supervisor Unix socket path %s: path must be absolute", + socket_env, + ) + + def _make_server(conf: ConfData) -> HomeAssistantHTTP: + return HomeAssistantHTTP( + hass, + server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), + server_port=conf[CONF_SERVER_PORT], + ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), + ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), + ssl_key=conf.get(CONF_SSL_KEY), + # The loaded config stores trusted proxies as strings + # (JSON-serializable); the forwarded middleware needs + # IPv4Network/IPv6Network objects. + trusted_proxies=[ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ], + ssl_profile=conf[CONF_SSL_PROFILE], + supervisor_unix_socket_path=supervisor_unix_socket_path, + ) + + server = _make_server(conf) + trial_reverted = False + while True: + try: + await server.async_bind() + except (HomeAssistantError, OSError) as err: + store = await async_get_and_load_store(hass) + trial_reverted = store.revert_deadline is not None + conf = await _async_fallback_config(hass, store, conf, err) + server = _make_server(conf) + continue + if trial_reverted: + _LOGGER.warning( + "The previous HTTP configuration has been restored (server port %d)", + conf[CONF_SERVER_PORT], + ) + break + + # Created only after the fallback chain succeeded: if setup fails above, + # an already running task would be left behind unawaited. + source_ip_task = create_eager_task(async_get_source_ip(hass)) + + async def stop_server(event: Event) -> None: + """Stop the server.""" + await server.stop() + + # Register the stop listener right away, not only once serving starts: + # sockets are already bound, and if the remainder of startup fails the + # recovery-mode teardown (which fires the stop event) must release them, + # or the recovery boot cannot bind the same address again. + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) + if CONF_SERVER_HOST in conf and is_hassio(hass): issue_id = "server_host_deprecated_hassio" ir.async_create_issue( @@ -202,60 +327,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) - ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) - ssl_key = conf.get(CONF_SSL_KEY) - cors_origins = conf[CONF_CORS_ORIGINS] - use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) - use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] - # The loaded config stores trusted proxies as strings (JSON-serializable); - # the forwarded middleware needs IPv4Network/IPv6Network objects. - trusted_proxies = [ - ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] - ] - is_ban_enabled = conf[CONF_IP_BAN_ENABLED] - login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] - ssl_profile = conf[CONF_SSL_PROFILE] - source_ip_task = create_eager_task(async_get_source_ip(hass)) - - supervisor_unix_socket_path: Path | None = None - if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): - socket_path = Path(socket_env) - if socket_path.is_absolute(): - supervisor_unix_socket_path = socket_path - else: - _LOGGER.error( - "Invalid Supervisor Unix socket path %s: path must be absolute", - socket_env, - ) - - server = HomeAssistantHTTP( - hass, - server_host=server_host, - server_port=server_port, - ssl_certificate=ssl_certificate, - ssl_peer_certificate=ssl_peer_certificate, - ssl_key=ssl_key, - trusted_proxies=trusted_proxies, - ssl_profile=ssl_profile, - supervisor_unix_socket_path=supervisor_unix_socket_path, - ) await server.async_initialize( - cors_origins=cors_origins, - use_x_forwarded_for=use_x_forwarded_for, - login_threshold=login_threshold, - is_ban_enabled=is_ban_enabled, - use_x_frame_options=use_x_frame_options, + cors_origins=conf[CONF_CORS_ORIGINS], + use_x_forwarded_for=conf.get(CONF_USE_X_FORWARDED_FOR, False), + login_threshold=conf[CONF_LOGIN_ATTEMPTS_THRESHOLD], + is_ban_enabled=conf[CONF_IP_BAN_ENABLED], + use_x_frame_options=conf[CONF_USE_X_FRAME_OPTIONS], ) - async def stop_server(event: Event) -> None: - """Stop the server.""" - await server.stop() - async def start_server(*_: Any) -> None: """Start the server.""" with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP): - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) await server.start() async_when_setup_or_start(hass, "frontend", start_server) @@ -397,9 +480,51 @@ class HomeAssistantHTTP: self.ssl_profile = ssl_profile self.supervisor_unix_socket_path = supervisor_unix_socket_path self.runner: web.AppRunner | None = None - self.site: HomeAssistantTCPSite | None = None self.supervisor_site: HomeAssistantUnixSite | None = None self.context: ssl.SSLContext | None = None + self._server: asyncio.Server | None = None + + async def async_bind(self) -> None: + """Create the SSL context and the server, binding its sockets. + + Called during setup so that an unusable configuration surfaces before + it is applied; serving starts later in ``start()``. Raises + ``HomeAssistantError`` if the SSL configuration is unusable and + ``OSError`` if the configured address cannot be bound. + """ + if self.ssl_certificate: + self.context = await self.hass.async_add_executor_job( + self._create_ssl_context + ) + self._server = await self._async_create_server() + + async def _async_create_server(self) -> asyncio.Server: + """Create the (not yet serving) HTTP server, binding its sockets.""" + try: + return await self.hass.loop.create_server( + self._make_protocol, + self.server_host if self.server_host is not None else _DEFAULT_BIND, + self.server_port, + ssl=self.context, + backlog=128, + start_serving=False, + ) + except UnicodeError as err: + # create_server() raises UnicodeError for hosts the IDNA codec + # cannot encode (e.g. a label longer than 63 characters); + # normalize to OSError so callers only need to handle one error + # type. + raise OSError(f"error while resolving host: {err}") from err + + def _make_protocol(self) -> RequestHandler: + """Create a protocol instance for an accepted connection. + + Connections are only accepted once ``start()`` has run, so the + runner is set up by the time this is called. + """ + runner = self.runner + assert runner is not None and runner.server is not None + return runner.server() async def async_initialize( self, @@ -430,11 +555,6 @@ class HomeAssistantHTTP: setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) - if self.ssl_certificate: - self.context = await self.hass.async_add_executor_job( - self._create_ssl_context - ) - def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: """Register a view with the WSGI server. @@ -555,12 +675,13 @@ class HomeAssistantHTTP: ) context = None else: + # Fall through: a configured peer certificate must still be + # enforced on the emergency context. _LOGGER.critical( "Home Assistant is running in recovery mode with an emergency self" " signed ssl certificate because the configured SSL certificate was" " not usable" ) - return context if self.ssl_peer_certificate: if context is None: @@ -570,7 +691,15 @@ class HomeAssistantHTTP: ) context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations(self.ssl_peer_certificate) + try: + context.load_verify_locations(self.ssl_peer_certificate) + except OSError as error: + # Raise HomeAssistantError so the caller can tell an unusable + # SSL configuration apart from a socket bind failure (OSError). + raise HomeAssistantError( + f"Could not use SSL peer certificate from" + f" {self.ssl_peer_certificate}: {error}" + ) from error return context @@ -663,15 +792,10 @@ class HomeAssistantHTTP: ) await self.runner.setup() - self.site = HomeAssistantTCPSite( - self.runner, self.server_host, self.server_port, ssl_context=self.context - ) - try: - await self.site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server at port %d: %s", self.server_port, error - ) + # Setup either binds the server or fails, so it is always available + # here. + assert self._server is not None + await self._server.start_serving() _LOGGER.info("Now listening on port %d", self.server_port) @@ -690,7 +814,8 @@ class HomeAssistantHTTP: self.supervisor_unix_socket_path, err, ) - if self.site is not None: - await self.site.stop() + if self._server is not None: + self._server.close() + await self._server.wait_closed() if self.runner is not None: await self.runner.cleanup() diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py index 7564780ba643..3406ad4d793f 100644 --- a/homeassistant/components/http/config.py +++ b/homeassistant/components/http/config.py @@ -364,6 +364,18 @@ class HTTPConfigStore: await self._hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART) + async def async_abort_trial(self) -> None: + """Abort the running pending-config trial and reinstate stable. + + Called during setup when the pending config cannot be applied at all + (its address cannot be bound or its SSL configuration is unusable). + Clears the pending config so this and future starts use stable. + """ + await self.async_load() + self._async_cancel_revert() + self._pending = None + await self._async_persist() + async def async_migrate_yaml(self, config: ConfData) -> None: """Migrate YAML config to storage as pending if not the same as the config used for recovery.""" await self.async_load() diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 0348021e1382..fd07e2df66b8 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -3,74 +3,9 @@ import asyncio from pathlib import Path import socket -from ssl import SSLContext from typing import override from aiohttp import web -from yarl import URL - - -class HomeAssistantTCPSite(web.BaseSite): - """HomeAssistant specific aiohttp Site. - - Vanilla TCPSite accepts only str as host. However, the underlying asyncio's - create_server() implementation does take a list of strings to bind to multiple - host IP's. To support multiple server_host entries (e.g. to enable dual-stack - explicitly), we would like to pass an array of strings. Bring our own - implementation inspired by TCPSite. - - Custom TCPSite can be dropped when https://github.com/aio-libs/aiohttp/pull/4894 - is merged. - """ - - __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port") - - def __init__( - self, - runner: web.BaseRunner, - host: str | list[str] | None, - port: int, - *, - ssl_context: SSLContext | None = None, - backlog: int = 128, - reuse_address: bool | None = None, - reuse_port: bool | None = None, - ) -> None: - """Initialize HomeAssistantTCPSite.""" - super().__init__( - runner, - ssl_context=ssl_context, - backlog=backlog, - ) - self._host = host - self._port = port - self._reuse_address = reuse_address - self._reuse_port = reuse_port - - @property - @override - def name(self) -> str: - """Return server URL.""" - scheme = "https" if self._ssl_context else "http" - host = self._host[0] if isinstance(self._host, list) else "0.0.0.0" - return str(URL.build(scheme=scheme, host=host, port=self._port)) - - @override - async def start(self) -> None: - """Start server.""" - await super().start() - loop = asyncio.get_running_loop() - server = self._runner.server - assert server is not None - self._server = await loop.create_server( - server, - self._host, - self._port, - ssl=self._ssl_context, - backlog=self._backlog, - reuse_address=self._reuse_address, - reuse_port=self._reuse_port, - ) class HomeAssistantUnixSite(web.BaseSite): diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index eb90737faba3..89a04a4b143d 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -3,9 +3,11 @@ from dataclasses import asdict from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -75,7 +77,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, # Gather information how this device is represented in Home Assistant entity_registry = er.async_get(hass) - data = async_redact_data(attr.asdict(device), REDACT_CONFIG) + data = async_redact_data(device_entry_as_dict(device), REDACT_CONFIG) data["entities"] = [] entities: list[dict[str, Any]] = data["entities"] diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index c6f19c79e252..670ad3bcb54d 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -142,17 +142,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): if removed_zones := previous_zones - current_zones: LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) for zone_id in removed_zones: - device_registry.async_update_device( - device_id=previous_zones_by_id[zone_id].id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(previous_zones_by_id[zone_id].id) if removed_controllers := previous_controllers - current_controllers: LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) for controller_id in removed_controllers: - device_registry.async_update_device( - device_id=previous_controllers_by_id[controller_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_controllers_by_id[controller_id].id ) if new_controller_ids := current_controllers - previous_controllers: diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index be00fad48545..0485c6ea9718 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2026.4.0"] + "requirements": ["pydrawise==2026.7.0"] } diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index 972dee03f3c3..dd7242ae2bc7 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -5,7 +5,11 @@ from typing import override from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,15 +27,6 @@ PARAM_PTZ_DOWN = "ptz_down" PARAM_PTZ_LEFT = "ptz_left" PARAM_PTZ_RIGHT = "ptz_right" -BUTTON_TYPES = ( - PARAM_RESTART_DEVICE, - PARAM_MUTE, - PARAM_PTZ_UP, - PARAM_PTZ_DOWN, - PARAM_PTZ_LEFT, - PARAM_PTZ_RIGHT, -) - PTZ_BUTTON_TYPES = ( PARAM_PTZ_UP, PARAM_PTZ_DOWN, @@ -39,20 +34,43 @@ PTZ_BUTTON_TYPES = ( PARAM_PTZ_RIGHT, ) -BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = { - PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART, -} +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key=PARAM_RESTART_DEVICE, + device_class=ButtonDeviceClass.RESTART, + ), + ButtonEntityDescription( + key=PARAM_MUTE, + translation_key=PARAM_MUTE, + ), + ButtonEntityDescription( + key=PARAM_PTZ_UP, + translation_key=PARAM_PTZ_UP, + ), + ButtonEntityDescription( + key=PARAM_PTZ_DOWN, + translation_key=PARAM_PTZ_DOWN, + ), + ButtonEntityDescription( + key=PARAM_PTZ_LEFT, + translation_key=PARAM_PTZ_LEFT, + ), + ButtonEntityDescription( + key=PARAM_PTZ_RIGHT, + translation_key=PARAM_PTZ_RIGHT, + ), +) def _iter_buttons( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (button_type, device) pairs for supported buttons.""" +) -> list[tuple[ButtonEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported buttons.""" return [ - (button_type, device) + (description, device) for device in coordinator.devices - for button_type in device.buttons - if button_type in BUTTON_TYPES + for description in BUTTON_TYPES + if description.key in device.buttons ] @@ -67,8 +85,8 @@ async def async_setup_entry( def _add_buttons(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouButton(coordinator, button_type, device) - for button_type, device in _iter_buttons(coordinator) + ImouButton(coordinator, description, device) + for description, device in _iter_buttons(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,17 +104,7 @@ async def async_setup_entry( class ImouButton(ImouEntity, ButtonEntity): """Imou button entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou button entity.""" - super().__init__(coordinator, entity_type, device) - if device_class := BUTTON_DEVICE_CLASS.get(entity_type): - self._attr_device_class = device_class - self._attr_translation_key = None + entity_description: ButtonEntityDescription @override async def async_press(self) -> None: diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index a06a413b80b0..79acdedc9c44 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -1,12 +1,17 @@ """Support for Imou camera entities.""" +from dataclasses import dataclass from typing import override from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,9 +28,25 @@ CAMERA_STREAM_RESOLUTION_SD = "SD" PYIMOUAPI_LIVE_PROTOCOL = "https" PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3 -CAMERA_TYPES = ( - ("camera_sd", CAMERA_STREAM_RESOLUTION_SD), - ("camera_hd", PARAM_HD), + +@dataclass(frozen=True, kw_only=True) +class ImouCameraEntityDescription(CameraEntityDescription): + """Describes an Imou camera entity.""" + + resolution: str + + +CAMERA_TYPES: tuple[ImouCameraEntityDescription, ...] = ( + ImouCameraEntityDescription( + key="camera_sd", + translation_key="camera_sd", + resolution=CAMERA_STREAM_RESOLUTION_SD, + ), + ImouCameraEntityDescription( + key="camera_hd", + translation_key="camera_hd", + resolution=PARAM_HD, + ), ) @@ -40,11 +61,11 @@ async def async_setup_entry( def _add_cameras(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouCamera(coordinator, entity_type, device, resolution) + ImouCamera(coordinator, description, device) for device in coordinator.devices if device.channel_id is not None if imou_device_identifier(device) in device_keys - for entity_type, resolution in CAMERA_TYPES + for description in CAMERA_TYPES ) coordinator.new_device_callbacks.append(_add_cameras) @@ -61,19 +82,18 @@ async def async_setup_entry( class ImouCamera(ImouEntity, Camera): """Representation of an Imou camera stream.""" + entity_description: ImouCameraEntityDescription _attr_supported_features = CameraEntityFeature.STREAM def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: ImouCameraEntityDescription, device: ImouHaDevice, - resolution: str, ) -> None: """Initialize the camera entity.""" - self._resolution = resolution Camera.__init__(self) - super().__init__(coordinator, entity_type, device) + super().__init__(coordinator, description, device) @override async def stream_source(self) -> str | None: @@ -81,7 +101,7 @@ class ImouCamera(ImouEntity, Camera): try: return await self.coordinator.device_manager.async_get_device_stream( self.device, - self._resolution, + self.entity_description.resolution, PYIMOUAPI_LIVE_PROTOCOL, ) except ImouException as err: diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py index ea21763eb946..e9c25f64dbeb 100644 --- a/homeassistant/components/imou/entity.py +++ b/homeassistant/components/imou/entity.py @@ -5,6 +5,7 @@ from typing import override from pyimouapi.ha_device import DeviceStatus, ImouHaDevice from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier @@ -19,15 +20,15 @@ class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]): def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: EntityDescription, device: ImouHaDevice, ) -> None: """Initialize the Imou entity.""" super().__init__(coordinator) - self._entity_type = entity_type + self.entity_description = description + self._entity_type = description.key self._device_key = imou_device_identifier(device) - self._attr_unique_id = f"{self._device_key}${entity_type}" - self._attr_translation_key = entity_type + self._attr_unique_id = f"{self._device_key}${description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_key)}, name=device.channel_name or device.device_name, diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index caed3462950b..be6b7764127d 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -5,7 +5,11 @@ from typing import Any, override from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,32 +31,53 @@ from .entity import ImouEntity PARALLEL_UPDATES = 0 -SWITCH_TYPES = ( - PARAM_AB_ALARM_SOUND, - PARAM_AUDIO_ENCODE_CONTROL, - PARAM_CLOSE_CAMERA, - PARAM_HEADER_DETECT, - PARAM_LIGHT, - PARAM_MOTION_DETECT, - PARAM_PLUG_SWITCH, - PARAM_WHITE_LIGHT, +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=PARAM_AB_ALARM_SOUND, + translation_key=PARAM_AB_ALARM_SOUND, + ), + SwitchEntityDescription( + key=PARAM_AUDIO_ENCODE_CONTROL, + translation_key=PARAM_AUDIO_ENCODE_CONTROL, + ), + SwitchEntityDescription( + key=PARAM_CLOSE_CAMERA, + translation_key=PARAM_CLOSE_CAMERA, + ), + SwitchEntityDescription( + key=PARAM_HEADER_DETECT, + translation_key=PARAM_HEADER_DETECT, + ), + SwitchEntityDescription( + key=PARAM_LIGHT, + translation_key=PARAM_LIGHT, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_MOTION_DETECT, + translation_key=PARAM_MOTION_DETECT, + ), + SwitchEntityDescription( + key=PARAM_PLUG_SWITCH, + translation_key=PARAM_PLUG_SWITCH, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_WHITE_LIGHT, + translation_key=PARAM_WHITE_LIGHT, + ), ) -SWITCH_DEVICE_CLASS: dict[str, SwitchDeviceClass] = { - PARAM_LIGHT: SwitchDeviceClass.SWITCH, - PARAM_PLUG_SWITCH: SwitchDeviceClass.SWITCH, -} - def _iter_switches( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (switch_type, device) pairs for supported switches.""" +) -> list[tuple[SwitchEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported switches.""" return [ - (switch_type, device) + (description, device) for device in coordinator.devices - for switch_type in device.switches - if switch_type in SWITCH_TYPES + for description in SWITCH_TYPES + if description.key in device.switches ] @@ -67,8 +92,8 @@ async def async_setup_entry( def _add_switches(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouSwitch(coordinator, switch_type, device) - for switch_type, device in _iter_switches(coordinator) + ImouSwitch(coordinator, description, device) + for description, device in _iter_switches(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,15 +111,7 @@ async def async_setup_entry( class ImouSwitch(ImouEntity, SwitchEntity): """Imou switch entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou switch entity.""" - super().__init__(coordinator, entity_type, device) - self._attr_device_class = SWITCH_DEVICE_CLASS.get(entity_type) + entity_description: SwitchEntityDescription @property @override diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 7f699ac774d1..8958e81d9eba 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==7.0.0"] + "requirements": ["infrared-protocols==7.5.0"] } diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index eb8650dc6490..1a0bf8401f76 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 4feef90a7f72..ffe8bed9117f 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.4.0"] + "requirements": ["intellifire4py==4.5.0"] } diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py index 2664e3e12d25..0cd48b263f05 100644 --- a/homeassistant/components/ituran/coordinator.py +++ b/homeassistant/components/ituran/coordinator.py @@ -73,6 +73,4 @@ class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]): ) for device in device_entries: if not device.identifiers.intersection(account_vehicles): - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index c3d17460e31d..3afc4d252e7e 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, Platform +from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -108,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, unique_id=controller.device_uid, title=new_title, + data={CONF_HOST: controller.device_ip}, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 8525473c34df..7c526a0d3ae4 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -242,8 +242,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(uid) self._abort_if_unique_id_configured() - # Discovery host is for confirm-step context only; runtime discovery owns - # current device IP state and keeps it up to date independently of entry data. + # Persist through confirm into entry data as CONF_HOST. self._discovered_controller_ip = host return await self.async_step_confirm() @@ -357,7 +356,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( title=self._entry_title(controller.device_uid), - data={}, + data={CONF_HOST: controller.device_ip}, ) @callback diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index da55de678ce9..5b89df9f9b91 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pizone"], - "requirements": ["python-izone==1.3.4"] + "requirements": ["python-izone==1.3.6"] } diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6ae46c3173be..6dff1f12f512 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -24,11 +24,13 @@ from .const import ( CONF_KNX_KNXKEY_FILENAME, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DATA_HASS_CONFIG, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_PATH_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, @@ -188,11 +190,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER) new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT) + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, version=2 + entry, data=new_data, options=new_options, version=2, minor_version=2 ) _LOGGER.info("Migration to version 2 successful") + if entry.version == 2 and entry.minor_version < 2: + # version 2.2 introduced in 2026.8 + new_options = {**entry.options} + if CONF_KNX_TELEGRAM_DB_BACKEND not in new_options: + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( + entry, options=new_options, minor_version=2 + ) + _LOGGER.info("Migration to version 2.2 successful") + return True diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 50a2c7206b44..c612f26714d4 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -1,8 +1,12 @@ """Config flow for KNX.""" +import asyncio from collections.abc import AsyncGenerator from typing import Any, Final, Literal, override +from urllib.parse import quote, unquote, urlparse, urlunparse +from knx_telegram_store import ConnectionErrorKind +from knx_telegram_store.backends.postgres import PostgresStore import voluptuous as vol from xknx import XKNX from xknx.exceptions.exception import ( @@ -49,8 +53,16 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_DATABASE, + CONF_KNX_TELEGRAM_DB_HOST, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_PASSWORD, + CONF_KNX_TELEGRAM_DB_PORT, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + CONF_KNX_TELEGRAM_DB_TLS, + CONF_KNX_TELEGRAM_DB_USER, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, @@ -58,6 +70,8 @@ from .const import ( DEFAULT_ROUTING_IA, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -82,12 +96,17 @@ DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions( state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT, telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + telegram_db_backend=KNX_TELEGRAM_BACKEND_SQLITE, ) CONF_KEYRING_FILE: Final = "knxkeys_file" CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section" +# Timeout for the PostgreSQL connection check, so an unreachable host cannot +# block the options flow until the driver/OS connection timeout expires. +DSN_CHECK_TIMEOUT = 10 + CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING: "UDP (Tunneling v1)", @@ -113,6 +132,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize KNX config flow.""" @@ -951,6 +971,7 @@ class KNXOptionsFlow(OptionsFlowWithReload): """Manage KNX communication settings.""" if user_input is not None: telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION] + backend = telegram_store_section[CONF_KNX_TELEGRAM_DB_BACKEND] self.new_entry_options |= KNXConfigEntryOptions( state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], @@ -960,7 +981,10 @@ class KNXOptionsFlow(OptionsFlowWithReload): telegram_db_retention_days=telegram_store_section[ CONF_KNX_TELEGRAM_DB_RETENTION_DAYS ], + telegram_db_backend=backend, ) + if backend == KNX_TELEGRAM_BACKEND_POSTGRES: + return await self.async_step_telegram_store_postgres() return self.finish_flow() data_schema = { @@ -1020,6 +1044,22 @@ class KNXOptionsFlow(OptionsFlowWithReload): ), vol.Coerce(int), ), + vol.Required( + CONF_KNX_TELEGRAM_DB_BACKEND, + default=self.initial_options.get( + CONF_KNX_TELEGRAM_DB_BACKEND, + KNX_TELEGRAM_BACKEND_SQLITE, + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + KNX_TELEGRAM_BACKEND_SQLITE, + KNX_TELEGRAM_BACKEND_POSTGRES, + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="telegram_backend", + ) + ), } ), ), @@ -1027,5 +1067,136 @@ class KNXOptionsFlow(OptionsFlowWithReload): return self.async_show_form( step_id="communication_settings", data_schema=vol.Schema(data_schema), + last_step=False, + ) + + async def async_step_telegram_store_postgres( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect and validate the PostgreSQL telegram store connection.""" + current_dsn = self.initial_options.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "") + parsed = _parse_dsn(current_dsn) + errors: dict[str, str] = {} + + if user_input is not None: + # Reuse the stored password when the field is left blank. + params = { + **user_input, + CONF_KNX_TELEGRAM_DB_PASSWORD: ( + user_input.get(CONF_KNX_TELEGRAM_DB_PASSWORD) + or parsed.get(CONF_KNX_TELEGRAM_DB_PASSWORD, "") + ), + } + dsn = _build_dsn(params) + errors = await _async_check_postgres_dsn(dsn) + if not errors: + self.new_entry_options |= KNXConfigEntryOptions( + telegram_db_postgres_dsn=dsn + ) + return self.finish_flow() + + data_schema = vol.Schema( + { + vol.Required( + CONF_KNX_TELEGRAM_DB_HOST, + default=parsed.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PORT, + default=parsed.get(CONF_KNX_TELEGRAM_DB_PORT, 5432), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=65535, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_USER, + default=parsed.get(CONF_KNX_TELEGRAM_DB_USER, ""), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PASSWORD, default="" + ): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_DATABASE, + default=parsed.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_TLS, + default=parsed.get(CONF_KNX_TELEGRAM_DB_TLS, False), + ): selector.BooleanSelector(), + } + ) + if user_input is not None: + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + return self.async_show_form( + step_id="telegram_store_postgres", + data_schema=data_schema, + errors=errors, last_step=True, ) + + +async def _async_check_postgres_dsn(dsn: str) -> dict[str, str]: + """Validate a PostgreSQL DSN, returning form errors on failure.""" + connection_errors = { + ConnectionErrorKind.AUTH: "invalid_auth", + ConnectionErrorKind.HOST_UNREACHABLE: "host_unreachable", + ConnectionErrorKind.DATABASE_MISSING: "database_missing", + ConnectionErrorKind.PERMISSION: "permission", + ConnectionErrorKind.TIMEOUT: "timeout", + ConnectionErrorKind.MISSING_DEPENDENCY: "missing_dependency", + } + try: + async with asyncio.timeout(DSN_CHECK_TIMEOUT): + check_result = await PostgresStore.check_config(dsn) + except TimeoutError: + return {"base": "timeout"} + except ValueError: + return {"base": "cannot_connect"} + if not check_result.ok: + return {"base": connection_errors.get(check_result.kind, "cannot_connect")} + return {} + + +def _build_dsn(params: dict[str, Any]) -> str: + """Build a PostgreSQL DSN from form params.""" + quoted_user = quote(params.get(CONF_KNX_TELEGRAM_DB_USER, ""), safe="") + quoted_password = quote(params.get(CONF_KNX_TELEGRAM_DB_PASSWORD, ""), safe="") + host = params.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost") + if ":" in host and not host.startswith("["): + # IPv6 literals must be bracketed in the URL netloc + host = f"[{host}]" + port = int(params.get(CONF_KNX_TELEGRAM_DB_PORT, 5432)) + quoted_database = quote( + params.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), safe="" + ) + tls = params.get(CONF_KNX_TELEGRAM_DB_TLS, False) + + netloc = f"{quoted_user}:{quoted_password}@{host}:{port}" + query = "sslmode=require" if tls else "" + return urlunparse(("postgresql", netloc, f"/{quoted_database}", "", query, "")) + + +def _parse_dsn(dsn: str) -> dict[str, Any]: + """Parse a PostgreSQL DSN into form params.""" + if not dsn: + return {} + try: + url = urlparse(dsn) + return { + CONF_KNX_TELEGRAM_DB_USER: unquote(url.username or ""), + CONF_KNX_TELEGRAM_DB_PASSWORD: unquote(url.password or ""), + CONF_KNX_TELEGRAM_DB_HOST: url.hostname or "localhost", + CONF_KNX_TELEGRAM_DB_PORT: url.port or 5432, + CONF_KNX_TELEGRAM_DB_DATABASE: unquote(url.path.lstrip("/")), + CONF_KNX_TELEGRAM_DB_TLS: "sslmode=require" in url.query, + } + except ValueError, AttributeError: + return {} diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 84f73b4255e2..f1c203d18a33 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -53,8 +53,20 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0 DEFAULT_ROUTING_IA: Final = "0.0.240" +CONF_KNX_TELEGRAM_DB_BACKEND: Final = "telegram_db_backend" CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days" CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours" +CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: Final = "telegram_db_postgres_dsn" + +CONF_KNX_TELEGRAM_DB_HOST: Final = "host" +CONF_KNX_TELEGRAM_DB_PORT: Final = "port" +CONF_KNX_TELEGRAM_DB_USER: Final = "user" +CONF_KNX_TELEGRAM_DB_PASSWORD: Final = "password" +CONF_KNX_TELEGRAM_DB_DATABASE: Final = "database" +CONF_KNX_TELEGRAM_DB_TLS: Final = "tls" + +KNX_TELEGRAM_BACKEND_SQLITE: Final = "sqlite" +KNX_TELEGRAM_BACKEND_POSTGRES: Final = "postgres" KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day @@ -139,6 +151,8 @@ class KNXConfigEntryOptions(TypedDict, total=False): # Integration only (not forwarded to xknx) telegram_db_retention_days: int telegram_db_load_hours: int + telegram_db_backend: str # sqlite | postgres + telegram_db_postgres_dsn: str class ColorTempModes(Enum): diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index e84c9f2c7941..ec7c7cb2c220 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -79,10 +79,8 @@ class _KNXDate(DateEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDate.from_date( dt_date.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 91c81eba8f15..04674fa4cd28 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -80,10 +80,8 @@ class _KNXDateTime(DateTimeEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDateTime.from_datetime( datetime.fromisoformat(last_state.state).astimezone( dt_util.get_default_time_zone() diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index c685a5123b0c..d637eb551888 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -15,6 +15,7 @@ from .const import ( CONF_KNX_ROUTING_BACKBONE_KEY, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DOMAIN, KNX_MODULE_KEY, ) @@ -24,6 +25,7 @@ TO_REDACT = { CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a67d99cc3c6a..af9d377e6c5b 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.16.0", "xknxproject==3.9.0", "knx-frontend==2026.6.23.203726", - "knx-telegram-store[sqlite]==0.3.2" + "knx-telegram-store[sqlite,postgres]==0.10.2" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index db59b9527eb5..b6102c805e86 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -82,10 +82,8 @@ class _KnxNumber(RestoreNumber): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if ( - not self._device.sensor_value.readable - and (last_state := await self.async_get_last_state()) - and (last_number_data := await self.async_get_last_number_data()) + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() ): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.sensor_value.value = last_number_data.native_value diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index f67465291dc5..b9079ac9ee30 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -83,9 +83,7 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if ( last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) and (option := self._option_payloads.get(last_state.state)) is not None diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 59ff173b8b20..ddc1f06465c7 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1025,7 +1025,18 @@ "title": "Information" }, "project": { - "description": "Inspect imported group addresses", + "description": "Inspect imported project", + "devices": { + "channels": "Channels", + "group_objects": "Group objects", + "lines": "Lines", + "locations": "Locations", + "not_found": "No devices found in project data.", + "title": "Devices" + }, + "group_addresses": { + "title": "[%key:component::knx::config_panel::common::group_addresses%]" + }, "title": "Project" }, "selectors": { @@ -1162,6 +1173,15 @@ } }, "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "database_missing": "The specified database does not exist.", + "host_unreachable": "Could not reach the database host.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_dependency": "Required database driver is not installed.", + "permission": "Insufficient privileges to access the database.", + "timeout": "Connection timed out." + }, "step": { "communication_settings": { "data": { @@ -1175,10 +1195,12 @@ "sections": { "telegram_store_section": { "data": { + "telegram_db_backend": "Telegram storage backend", "telegram_db_load_hours": "Group monitor history", "telegram_db_retention_days": "Retention period" }, "data_description": { + "telegram_db_backend": "Select where to store KNX telegram history.", "telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.", "telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run." }, @@ -1186,6 +1208,25 @@ } }, "title": "Communication settings" + }, + "telegram_store_postgres": { + "data": { + "database": "Database name", + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "tls": "Use TLS", + "user": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "database": "Name of the database to store telegrams in.", + "host": "Hostname or IP address of the PostgreSQL server.", + "password": "Password for the PostgreSQL user. Leave blank to keep the current password.", + "port": "Port the PostgreSQL server is listening on.", + "tls": "Encrypt the connection to the PostgreSQL server (`sslmode=require`). Note that the server certificate is not verified.", + "user": "Username to authenticate with the PostgreSQL server." + }, + "title": "PostgreSQL connection" } } }, @@ -1260,6 +1301,12 @@ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" } + }, + "telegram_backend": { + "options": { + "postgres": "PostgreSQL (External)", + "sqlite": "Internal storage (Default)" + } } }, "services": { diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 3d48589d2451..0e7acb36dfe8 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -8,6 +8,7 @@ import os from typing import Any, TypedDict from knx_telegram_store import ( + BufferedPostgresStore, BufferedSqliteStore, KnxTelegramStoreException, StoredTelegram, @@ -26,7 +27,10 @@ from homeassistant.helpers.storage import STORAGE_DIR, Store from homeassistant.util import dt as dt_util from .const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + KNX_TELEGRAM_BACKEND_POSTGRES, KNX_TELEGRAM_DB_PATH_SQLITE, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, SIGNAL_KNX_TELEGRAM, @@ -48,6 +52,15 @@ EVICT_EXPIRED_HOUR = 3 # at risk from a longer interval are those buffered during an ungraceful shutdown. FLUSH_INTERVAL_SECONDS = 600 +# The buffer drops the oldest telegrams when full. Size it to cover a full +# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so +# nothing is dropped while the database is healthy. +MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50 + +# Timeout for the migration probe and store initialization, so an unreachable +# database cannot block KNX setup until the driver/OS connection timeout expires. +STORE_INIT_TIMEOUT = 10 + class DecodedTelegramPayload(TypedDict): """Decoded payload value and metadata.""" @@ -89,19 +102,32 @@ class Telegrams: self.project = project self.config = config + self.backend: str = config[CONF_KNX_TELEGRAM_DB_BACKEND] + self.dsn: str = str(config.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "")) self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] - self.store: BufferedSqliteStore | None = None - self._uninitialized_store: BufferedSqliteStore | None = None + self.store: BufferedSqliteStore | BufferedPostgresStore | None = None + self._uninitialized_store: ( + BufferedSqliteStore | BufferedPostgresStore | None + ) = None self._evict_expired_unsub: CALLBACK_TYPE | None = None - full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) - os.makedirs(os.path.dirname(full_path), exist_ok=True) - self._uninitialized_store = BufferedSqliteStore( - full_path, - retention_days=self.retention_days, - flush_interval=FLUSH_INTERVAL_SECONDS, - ) + if self.backend == KNX_TELEGRAM_BACKEND_POSTGRES: + self._uninitialized_store = BufferedPostgresStore( + self.dsn, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) + else: + full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + self._uninitialized_store = BufferedSqliteStore( + full_path, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) self._xknx_telegram_cb_handle = ( xknx.telegram_queue.register_telegram_received_cb( @@ -121,7 +147,8 @@ class Telegrams: if self._uninitialized_store is None: return try: - needs_migration = await self._uninitialized_store.needs_migration() + async with asyncio.timeout(STORE_INIT_TIMEOUT): + needs_migration = await self._uninitialized_store.needs_migration() if needs_migration: _LOGGER.warning( "KNX telegram history database schema upgrade/migration is required. " @@ -129,24 +156,35 @@ class Telegrams: ) await self._uninitialized_store.initialize() else: - _LOGGER.debug("Initializing KNX telegram storage") - async with asyncio.timeout(10): + _LOGGER.debug( + "Initializing KNX telegram storage backend '%s'", + self.backend, + ) + async with asyncio.timeout(STORE_INIT_TIMEOUT): await self._uninitialized_store.initialize() - _LOGGER.info("Successfully initialized KNX telegram storage") + _LOGGER.info( + "Successfully initialized KNX telegram storage backend '%s'", + self.backend, + ) except TimeoutError: - _LOGGER.error("Timeout initializing KNX telegram storage") + _LOGGER.error( + "Timeout initializing KNX telegram storage backend '%s'", + self.backend, + ) await self._abort_store_init() return except KnxTelegramStoreException as err: _LOGGER.error( - "Database error initializing KNX telegram storage: %s", + "Database error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() return except Exception as err: # noqa: BLE001 _LOGGER.error( - "Error initializing KNX telegram storage: %s", + "Error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index d96c41dc45ac..c42e1863e174 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -81,9 +81,7 @@ class _KnxText(TextEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = last_state.state diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 99e16b0a2beb..dd42a23cf397 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -79,10 +79,8 @@ class _KNXTime(TimeEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XknxTime.from_time( dt_time.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 4a79f7cdd9b0..568de4fe8220 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -8,7 +8,12 @@ import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel -from knx_telegram_store import KnxTelegramStoreException, TelegramQuery +from knx_telegram_store import ( + BufferedPostgresStore, + BufferedSqliteStore, + KnxTelegramStoreException, + TelegramQuery, +) import voluptuous as vol from xknx.telegram import Telegram from xknxproject.exceptions import XknxProjectException @@ -200,7 +205,11 @@ def ws_get_base_data( "connected": knx.xknx.connection_manager.connected.is_set(), "current_address": str(knx.xknx.current_address), "telegram_backend": ( - "sqlite" if knx.telegrams.store is not None else "unknown" + "sqlite" + if isinstance(knx.telegrams.store, BufferedSqliteStore) + else "postgres" + if isinstance(knx.telegrams.store, BufferedPostgresStore) + else "unknown" ), "telegram_retention": knx.telegrams.store.retention_days if knx.telegrams.store is not None diff --git a/homeassistant/components/led_infrared/__init__.py b/homeassistant/components/led_infrared/__init__.py new file mode 100644 index 000000000000..12d4096ea426 --- /dev/null +++ b/homeassistant/components/led_infrared/__init__.py @@ -0,0 +1,18 @@ +"""The LED Infrared integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LED Infrared from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py new file mode 100644 index 000000000000..dc5998297723 --- /dev/null +++ b/homeassistant/components/led_infrared/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow for the LED Infrared integration.""" + +from typing import TYPE_CHECKING, Any, override + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +DEVICE_NAMES = { + LEDIrDeviceType.GENERIC_24_KEY: "24-key remote", + LEDIrDeviceType.GENERIC_13_KEY: "13-key remote", +} + + +class LEDIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LED Infrared.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + + title_entity_id = emitter_id + if TYPE_CHECKING: + assert title_entity_id is not None + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(title_entity_id) + title_entity_name = ( + entry.name or entry.original_name or title_entity_id + if entry + else title_entity_id + ) + return self.async_create_entry( + title=f"LED light with {DEVICE_NAMES[LEDIrDeviceType(user_input[CONF_DEVICE_TYPE])]} via {title_entity_name}", + data=user_input, + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): SelectSelector( + SelectSelectorConfig( + options=[ + device_type.value for device_type in LEDIrDeviceType + ], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + errors=errors, + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/led_infrared" + }, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ) + } + ), + entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/led_infrared/const.py b/homeassistant/components/led_infrared/const.py new file mode 100644 index 000000000000..7c5295f2b586 --- /dev/null +++ b/homeassistant/components/led_infrared/const.py @@ -0,0 +1,14 @@ +"""Constants for the LED Infrared integration.""" + +from enum import StrEnum + +DOMAIN = "led_infrared" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_DEVICE_TYPE = "device_type" + + +class LEDIrDeviceType(StrEnum): + """LED Infrared device types.""" + + GENERIC_24_KEY = "generic_24_key" + GENERIC_13_KEY = "generic_13_key" diff --git a/homeassistant/components/led_infrared/diagnostics.py b/homeassistant/components/led_infrared/diagnostics.py new file mode 100644 index 000000000000..cd74ce1614ee --- /dev/null +++ b/homeassistant/components/led_infrared/diagnostics.py @@ -0,0 +1,14 @@ +"""Diagnostics platform for the LED Infrared integration.""" + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return dict(config_entry.data) diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json new file mode 100644 index 000000000000..d1e1784e95ce --- /dev/null +++ b/homeassistant/components/led_infrared/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "mdi:palette", + "cyan": "mdi:palette", + "dark_cyan": "mdi:palette", + "fade": "mdi:gradient-horizontal", + "flash": "mdi:flash", + "green": "mdi:palette", + "light_green": "mdi:palette", + "mode_1": "mdi:numeric-1-box", + "mode_2": "mdi:numeric-2-box", + "mode_3": "mdi:numeric-3-box", + "mode_4": "mdi:numeric-4-box", + "mode_5": "mdi:numeric-5-box", + "mode_6": "mdi:numeric-6-box", + "mode_7": "mdi:numeric-7-box", + "mode_8": "mdi:numeric-8-box", + "orange": "mdi:palette", + "orange_red": "mdi:palette", + "plum": "mdi:palette", + "purple": "mdi:palette", + "rebecca_purple": "mdi:palette", + "red": "mdi:palette", + "sky_blue": "mdi:palette", + "smooth": "mdi:looks", + "strobe": "mdi:light-flood-down", + "tomato": "mdi:palette", + "turquoise": "mdi:palette", + "white": "mdi:palette", + "yellow": "mdi:palette" + } + } + } + } + } + } +} diff --git a/homeassistant/components/led_infrared/light.py b/homeassistant/components/led_infrared/light.py new file mode 100644 index 000000000000..7f0d385218a1 --- /dev/null +++ b/homeassistant/components/led_infrared/light.py @@ -0,0 +1,129 @@ +"""Light platform for LED Infrared integration.""" + +from typing import Any, override + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.light import ( + ATTR_EFFECT, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +PARALLEL_UPDATES = 1 + +CODES = { + LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode, + LEDIrDeviceType.GENERIC_13_KEY: Generic13KeyCode, +} + + +SUPPORTED_EFFECTS = { + LEDIrDeviceType.GENERIC_24_KEY: ["flash", "strobe", "fade", "smooth"], + LEDIrDeviceType.GENERIC_13_KEY: [ + "mode_1", + "mode_2", + "mode_3", + "mode_4", + "mode_5", + "mode_6", + "mode_7", + "mode_8", + ], +} + + +SUPPORTED_COLORS = { + LEDIrDeviceType.GENERIC_24_KEY: [ + "red", + "green", + "blue", + "white", + "tomato", + "light_green", + "sky_blue", + "orange_red", + "cyan", + "rebecca_purple", + "orange", + "turquoise", + "purple", + "yellow", + "dark_cyan", + "plum", + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up platform from config entry.""" + if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)): + return + + async_add_entities( + [LEDIrLightEntity(entry, entry.data[CONF_DEVICE_TYPE], infrared_entity_id)] + ) + + +class LEDIrLightEntity(InfraredEmitterConsumerEntity, LightEntity): + """Represents a LED Infrared light entity.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_effect_list: list[str] + _attr_has_entity_name = True + _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "light" + + def __init__( + self, + entry: ConfigEntry, + device_type: LEDIrDeviceType, + infrared_entity_id: str, + ) -> None: + """Initialize the entity.""" + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + ) + + self._infrared_emitter_entity_id = infrared_entity_id + + self._codes = CODES[device_type] + self._attr_effect_list = SUPPORTED_EFFECTS.get( + device_type, [] + ) + SUPPORTED_COLORS.get(device_type, []) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self._send_command(self._codes.ON.to_command()) + self._attr_is_on = True + effect: str | None = kwargs.get(ATTR_EFFECT) + if effect and effect in self._attr_effect_list: + await self._send_command(self._codes[effect.upper()].to_command()) + self._attr_effect = effect + + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._send_command(self._codes.OFF.to_command()) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/led_infrared/manifest.json b/homeassistant/components/led_infrared/manifest.json new file mode 100644 index 000000000000..501f79a54b73 --- /dev/null +++ b/homeassistant/components/led_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "led_infrared", + "name": "LED Infrared", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/led_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml new file mode 100644 index 000000000000..5119557d203f --- /dev/null +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has only one platform + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during config flow. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration has no repairs. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not do HTTP requests. + strict-typing: done diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json new file mode 100644 index 000000000000..a7735543a8b6 --- /dev/null +++ b/homeassistant/components/led_infrared/strings.json @@ -0,0 +1,83 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured with this infrared entity.", + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "missing_infrared_entity": "Select an infrared emitter." + }, + "step": { + "reconfigure": { + "data": { + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "title": "Reconfigure LED Infrared device" + }, + "user": { + "data": { + "device_type": "[%key:common::generic::device_type%]", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "device_type": "The type of remote control used for the LED light bulb, lamp, or controller.", + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "description": "Select the device type and an infrared emitter. You can identify the correct device based on the remote control used. Please refer to the [documentation]({docs_url}).", + "title": "Set up LED Infrared device" + } + } + }, + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "Color: Blue", + "cyan": "Color: Cyan", + "dark_cyan": "Color: Dark cyan", + "fade": "Fade", + "flash": "Flash", + "green": "Color: Green", + "light_green": "Color: Light green", + "mode_1": "Mode 1", + "mode_2": "Mode 2", + "mode_3": "Mode 3", + "mode_4": "Mode 4", + "mode_5": "Mode 5", + "mode_6": "Mode 6", + "mode_7": "Mode 7", + "mode_8": "Mode 8", + "orange": "Color: Orange", + "orange_red": "Color: Orange red", + "plum": "Color: Plum", + "purple": "Color: Purple", + "rebecca_purple": "Color: Rebecca purple", + "red": "Color: Red", + "sky_blue": "Color: Sky blue", + "smooth": "Smooth", + "strobe": "Strobe", + "tomato": "Color: Tomato", + "turquoise": "Color: Turquoise", + "white": "Color: White", + "yellow": "Color: Yellow" + } + } + } + } + } + }, + "selector": { + "device_type": { + "options": { + "generic_13_key": "13-key remote control", + "generic_24_key": "24-key remote control" + } + } + } +} diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 0e614a8b363b..5cce4f3857a3 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -4,12 +4,13 @@ from collections.abc import Callable, Coroutine import logging from typing import Any, override +from aiohttp import ClientError from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState from homeassistant.core import callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,3 +113,10 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if on_fail_method: on_fail_method() raise ServiceValidationError(exc) from exc + except (TimeoutError, ClientError) as exc: + if on_fail_method: + on_fail_method() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from exc diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 4ded58f514fd..dd739a8ca21e 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -1163,6 +1163,9 @@ } }, "exceptions": { + "connection_error": { + "message": "Failed to connect to the LG ThinQ cloud. Please try again later." + }, "failed_to_connect_mqtt": { "message": "Failed to connect MQTT: {error}" } diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 8f596768f197..577cd1d737ba 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -106,10 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> for device_id in device_ids: if coordinator := data.coordinators.pop(device_id, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[LiebherrCoordinator] = [] diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 60aded43fb90..e84b2b465d3c 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -61,8 +61,7 @@ def async_migrate_entities_devices( migrated_devices.append(dev_entry.id) device_registry.async_update_device( dev_entry.id, - add_config_entry_id=new_entry.entry_id, - remove_config_entry_id=legacy_entry_id, + new_config_entry_id=new_entry.entry_id, ) entity_registry = er.async_get(hass) diff --git a/homeassistant/components/litellm/__init__.py b/homeassistant/components/litellm/__init__.py new file mode 100644 index 000000000000..5447eeb01454 --- /dev/null +++ b/homeassistant/components/litellm/__init__.py @@ -0,0 +1,33 @@ +"""The LiteLLM integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +PLATFORMS = [Platform.CONVERSATION] + + +async def async_setup_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Set up LiteLLM from a config entry.""" + coordinator = LiteLLMDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: LiteLLMConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Unload LiteLLM.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py new file mode 100644 index 000000000000..0b8df8be1d44 --- /dev/null +++ b/homeassistant/components/litellm/config_flow.py @@ -0,0 +1,253 @@ +"""Config flow for LiteLLM integration.""" + +import logging +from typing import Any, override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .const import ( + CONF_PROMPT, + DOMAIN, + PLACEHOLDER_API_KEY, + RECOMMENDED_CONVERSATION_OPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the proxy.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate the API key is invalid.""" + + +def _normalize_url(url: str) -> str: + """Normalize the proxy URL, ensuring it ends with the OpenAI `/v1` path.""" + parsed = URL(url.strip()) + path = parsed.path.rstrip("/") + if not path.endswith("/v1"): + path = f"{path}/v1" + return str(parsed.with_path(path)) + + +async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> list[str]: + """Fetch the available model names from the LiteLLM proxy. + + Uses the OpenAI-compatible `/v1/models` endpoint, which a LiteLLM proxy + serves with the configured model names. + """ + client = AsyncOpenAI( + base_url=url, + api_key=api_key or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + try: + return [ + model.id async for model in client.with_options(timeout=10.0).models.list() + ] + except (AuthenticationError, PermissionDeniedError) as err: + raise InvalidAuth from err + except OpenAIError as err: + raise CannotConnect from err + + +class LiteLLMConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LiteLLM.""" + + VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + url = _normalize_url(user_input[CONF_URL]) + api_key = user_input.get(CONF_API_KEY) + self._async_abort_entries_match({CONF_URL: url}) + try: + await _get_models(self.hass, url, api_key) + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + data = {CONF_URL: url} + if api_key: + data[CONF_API_KEY] = api_key + return self.async_create_entry( + title=URL(url).host or url, + data=data, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class LiteLLMSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for LiteLLM.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: list[str] = [] + + async def _fetch_models(self) -> None: + """Fetch models from the LiteLLM proxy.""" + entry = self._get_entry() + self.models = await _get_models( + self.hass, entry.data[CONF_URL], entry.data.get(CONF_API_KEY) + ) + + +class ConversationFlowHandler(LiteLLMSubentryFlowHandler): + """Handle conversation subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self.options: dict[str, Any] = {} + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a conversation agent.""" + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + return await self.async_step_init(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a conversation agent.""" + self.options = self._get_reconfigure_subentry().data.copy() + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage conversation agent configuration.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_MODEL], data=user_input + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + title=user_input[CONF_MODEL], + data=user_input, + ) + + try: + await self._fetch_models() + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + options = [SelectOptionDict(value=model, label=model) for model in self.models] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + + if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API): + valid_api_ids = {api["value"] for api in hass_apis} + self.options[CONF_LLM_HASS_API] = [ + api for api in suggested_llm_apis if api in valid_api_ids + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_MODEL, default=self.options.get(CONF_MODEL) + ): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": self.options.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/litellm/const.py b/homeassistant/components/litellm/const.py new file mode 100644 index 000000000000..8f645e234519 --- /dev/null +++ b/homeassistant/components/litellm/const.py @@ -0,0 +1,18 @@ +"""Constants for the LiteLLM integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "litellm" +LOGGER = logging.getLogger(__package__) + +# LiteLLM proxies may run without authentication. The OpenAI client requires a +# non-empty API key, so we send a placeholder when the user did not provide one. +PLACEHOLDER_API_KEY = "sk-no-key-required" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/litellm/conversation.py b/homeassistant/components/litellm/conversation.py new file mode 100644 index 000000000000..c6d979aba8dd --- /dev/null +++ b/homeassistant/components/litellm/conversation.py @@ -0,0 +1,69 @@ +"""Conversation support for LiteLLM.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LiteLLMConfigEntry +from .const import DOMAIN +from .entity import LiteLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LiteLLMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.get_subentries_of_type("conversation"): + async_add_entities( + [LiteLLMConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LiteLLMConversationEntity(LiteLLMEntity, conversation.ConversationEntity): + """LiteLLM conversation agent.""" + + _attr_name = None + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/litellm/coordinator.py b/homeassistant/components/litellm/coordinator.py new file mode 100644 index 000000000000..ecd856bf6fd8 --- /dev/null +++ b/homeassistant/components/litellm/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for the LiteLLM integration.""" + +from datetime import timedelta +from typing import override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, PLACEHOLDER_API_KEY + +# Ping the proxy hourly while it is reachable, and back off to once a minute +# while it is down so entities recover quickly once it returns. +UPDATE_INTERVAL_CONNECTED = timedelta(hours=1) +UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1) + +type LiteLLMConfigEntry = ConfigEntry[LiteLLMDataUpdateCoordinator] + + +class LiteLLMDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Own the OpenAI client and track LiteLLM proxy availability.""" + + config_entry: LiteLLMConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL_CONNECTED, + always_update=False, + ) + self.client = AsyncOpenAI( + base_url=config_entry.data[CONF_URL], + api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + + @override + async def _async_update_data(self) -> None: + """Ping the proxy to confirm it is reachable and authenticated.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + try: + async for _ in self.client.with_options(timeout=10.0).models.list(): + break + except (AuthenticationError, PermissionDeniedError) as err: + raise ConfigEntryAuthFailed from err + except OpenAIError as err: + raise UpdateFailed(err) from err + self.update_interval = UPDATE_INTERVAL_CONNECTED + + @callback + @override + def async_set_updated_data(self, data: None) -> None: + """Manually update data and reset to the connected interval.""" + self.update_interval = UPDATE_INTERVAL_CONNECTED + super().async_set_updated_data(data) + + @callback + def mark_connection_error(self) -> None: + """Flag the proxy as unreachable and schedule a quick recheck.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + if self.last_update_success: + self.last_update_success = False + self.async_update_listeners() + if self._listeners and not self.hass.is_stopping: + self._schedule_refresh() diff --git a/homeassistant/components/litellm/entity.py b/homeassistant/components/litellm/entity.py new file mode 100644 index 000000000000..dfd37a0e0f76 --- /dev/null +++ b/homeassistant/components/litellm/entity.py @@ -0,0 +1,211 @@ +"""Base entity for LiteLLM.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + unsupported_keys = {"oneOf", "anyOf", "allOf"} + schema = convert(tool.parameters, custom_serializer=custom_serializer) + schema = {k: v for k, v in schema.items() if k not in unsupported_keys} + + tool_spec = FunctionDefinition( + name=tool.name, + parameters=schema, + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json_dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json_dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the LiteLLM message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class LiteLLMEntity(CoordinatorEntity[LiteLLMDataUpdateCoordinator]): + """Base entity for LiteLLM.""" + + _attr_has_entity_name = True + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + super().__init__(entry.runtime_data) + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + model_args = { + "model": self.model, + "user": chat_log.conversation_id, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + coordinator = self.entry.runtime_data + client = coordinator.client + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except (openai.AuthenticationError, openai.PermissionDeniedError) as err: + # Re-check so the proxy is marked unavailable for the auth failure. + await coordinator.async_request_refresh() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.APIConnectionError as err: + coordinator.mark_connection_error() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.OpenAIError as err: + # Reachable but the request failed; keep the entity available. + coordinator.async_set_updated_data(None) + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + coordinator.async_set_updated_data(None) + break + else: + LOGGER.warning( + "Stopped after %s tool iterations with unresolved tool calls", + MAX_TOOL_ITERATIONS, + ) diff --git a/homeassistant/components/litellm/manifest.json b/homeassistant/components/litellm/manifest.json new file mode 100644 index 000000000000..595ec0710b38 --- /dev/null +++ b/homeassistant/components/litellm/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "litellm", + "name": "LiteLLM", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@luismalves"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/litellm", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.45.0"] +} diff --git a/homeassistant/components/litellm/quality_scale.yaml b/homeassistant/components/litellm/quality_scale.yaml new file mode 100644 index 000000000000..448664369678 --- /dev/null +++ b/homeassistant/components/litellm/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: done + comment: >- + the coordinator polls the proxy hourly for an availability check, backing + off to once a minute while it is unreachable + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: done + comment: >- + the conversation entity follows the coordinator and is marked unavailable + when the proxy cannot be reached + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: the conversation entity does not use entity categories + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/litellm/strings.json b/homeassistant/components/litellm/strings.json new file mode 100644 index 000000000000..c13cf5122080 --- /dev/null +++ b/homeassistant/components/litellm/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "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": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "api_key": "An optional LiteLLM API key or virtual key. Leave empty if your proxy does not require authentication.", + "url": "The base URL of your LiteLLM proxy, including the host and port" + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + }, + "description": "Configure the conversation agent" + } + } + } + } +} diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index de54159ad3b4..304b65315bfd 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -394,9 +394,7 @@ def _remove_via_devices( devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) for device in devices: if device.via_device_id == device_entry.id: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def async_remove_config_entry_device( diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py index d3e1417018a7..4a58bba960d8 100644 --- a/homeassistant/components/melcloud_home/common.py +++ b/homeassistant/components/melcloud_home/common.py @@ -1,13 +1,22 @@ """Commonly shared code for the MELCloud Home integration.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable +from typing import Any -from aiomelcloudhome import ATAUnit, ATWUnit +from aiomelcloudhome import ( + ATAUnit, + ATWUnit, + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import MelCloudHomeCoordinator @@ -33,6 +42,32 @@ def async_setup_unit_entities( _async_add_new_atw_units(list(coordinator.atw_units.values())) +async def perform_action( + coordinator: MelCloudHomeCoordinator, + coroutine: Coroutine[Any, Any, None], +) -> None: + """Perform a MELCloud Home action with error handling and coordinator refresh.""" + try: + await coroutine + except MelCloudHomeAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except MelCloudHomeConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except MelCloudHomeTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + else: + await coordinator.async_request_refresh() + + def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: """Return the client keyword argument selecting this unit.""" if isinstance(unit, ATAUnit): diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index f3d4f8ddb4cd..c24f246d8371 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -102,9 +102,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) @override async def _async_update_data(self) -> UserContext: diff --git a/homeassistant/components/melcloud_home/manifest.json b/homeassistant/components/melcloud_home/manifest.json index 0ba62597594d..63adffcef260 100644 --- a/homeassistant/components/melcloud_home/manifest.json +++ b/homeassistant/components/melcloud_home/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiomelcloudhome"], "quality_scale": "bronze", - "requirements": ["aiomelcloudhome==0.1.9"] + "requirements": ["aiomelcloudhome==0.2.1"] } diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py index 7cb18e6f0455..2c78d443922a 100644 --- a/homeassistant/components/melcloud_home/number.py +++ b/homeassistant/components/melcloud_home/number.py @@ -5,11 +5,6 @@ from dataclasses import dataclass from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.number import ( NumberDeviceClass, @@ -21,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities, unit_ids +from .common import async_setup_unit_entities, perform_action, unit_ids from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -182,32 +177,6 @@ ATW_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATWUnit], ...] = ( ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, @@ -269,7 +238,7 @@ class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): translation_domain=DOMAIN, translation_key=error_key, ) - await _perform_action( + await perform_action( self.coordinator, self.entity_description.set_value_fn( self.coordinator.client, self.unit, value @@ -315,7 +284,7 @@ class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity): translation_domain=DOMAIN, translation_key=error_key, ) - await _perform_action( + await perform_action( self.coordinator, self.entity_description.set_value_fn( self.coordinator.client, self.unit, value diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index 67d130d77947..2d0f6ab230e0 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -5,11 +5,6 @@ from dataclasses import dataclass from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,11 +13,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities, unit_ids -from .const import DOMAIN +from .common import async_setup_unit_entities, perform_action, unit_ids from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -109,32 +102,6 @@ ATW_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATWUnit], ...] = ( ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, @@ -189,7 +156,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -197,7 +164,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) @@ -234,7 +201,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -242,7 +209,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 8855df935101..8b41b852e3be 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -339,7 +339,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: _error = api_error if _error is not None: - _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], _error) + _LOGGER.debug("Mikrotik %s error: %s", entry[CONF_HOST], _error) if "invalid user name or password" in str(_error): raise LoginError from _error raise CannotConnect from _error diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index c93ca805da6a..c4935aa408cb 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["mitsubishi-comfort==0.3.2"] + "requirements": ["mitsubishi-comfort==0.5.0"] } diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7709841a3b96..30945c8a13df 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,6 +1,6 @@ { "domain": "modbus", - "name": "Manual Modbus", + "name": "Modbus", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 08d29cc9aafb..d0d78d726e05 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -90,6 +90,5 @@ }, "name": "Write register" } - }, - "title": "Manual Modbus" + } } diff --git a/homeassistant/components/modbus_connection/__init__.py b/homeassistant/components/modbus_connection/__init__.py deleted file mode 100644 index c09aca8ba8a3..000000000000 --- a/homeassistant/components/modbus_connection/__init__.py +++ /dev/null @@ -1,99 +0,0 @@ -"""The Modbus Connection integration.""" - -from collections.abc import Mapping -from typing import Any, cast - -from modbus_connection import ModbusConnection, ModbusError, ModbusUnit -from modbus_connection.tmodbus import connect_serial, connect_tcp - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - DOMAIN, -) -from .exceptions import ConnectionNotReady - -__all__ = ["ConnectionNotReady", "async_get_unit"] - -type ModbusConnectionConfigEntry = ConfigEntry[ModbusConnection] - - -async def _async_open(data: Mapping[str, Any]) -> ModbusConnection: - """Open the connection described by ``data`` (transport parameters). - - Shared by config-entry setup and the config flow's validation; the caller - owns the returned connection and closes it. - """ - if data[CONF_TYPE] == CONNECTION_SERIAL: - return await connect_serial( - data[CONF_DEVICE], - baudrate=data[CONF_BAUDRATE], - bytesize=data[CONF_BYTESIZE], - parity=data[CONF_PARITY], - stopbits=data[CONF_STOPBITS], - ) - return await connect_tcp(data[CONF_HOST], port=data[CONF_PORT]) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ModbusConnectionConfigEntry -) -> bool: - """Set up a Modbus connection from a config entry.""" - try: - connection = await _async_open(entry.data) - except ModbusError as err: - raise ConfigEntryNotReady(f"Could not open Modbus connection: {err}") from err - - entry.runtime_data = connection - - # The connection is transient and does not self-reconnect: on a drop, reload - # this entry. HA's ConfigEntryNotReady retry is the reconnect backoff. - entry.async_on_unload( - connection.on_connection_lost( - lambda: hass.config_entries.async_schedule_reload(entry.entry_id) - ) - ) - - return True - - -async def async_unload_entry( - hass: HomeAssistant, entry: ModbusConnectionConfigEntry -) -> bool: - """Unload a config entry and close the owned connection.""" - await entry.runtime_data.close() - return True - - -@callback -def async_get_unit( - hass: HomeAssistant, connection_entry_id: str, unit_id: int -) -> ModbusUnit: - """Return a Modbus unit on a shared connection. - - Consumer integrations call this to borrow a ``ModbusUnit`` bound to their - unit ID; the ``ModbusConnection`` itself never leaves this integration. - - Raises ``ValueError`` if ``connection_entry_id`` is unknown or does not point - at a ``modbus_connection`` entry (a programming error in the consumer). Raises - ``ConnectionNotReady`` if that entry exists but is not loaded; it is a - ``ConfigEntryNotReady``, so a consumer can let it propagate from its own - ``async_setup_entry`` to get Home Assistant's setup retry. - """ - entry = cast( - "ModbusConnectionConfigEntry | None", - hass.config_entries.async_get_entry(connection_entry_id), - ) - if entry is None or entry.domain != DOMAIN: - raise ValueError(f"{connection_entry_id} is not a modbus_connection entry") - if entry.state is not ConfigEntryState.LOADED: - raise ConnectionNotReady(connection_entry_id) - return entry.runtime_data.for_unit(unit_id) diff --git a/homeassistant/components/modbus_connection/config_flow.py b/homeassistant/components/modbus_connection/config_flow.py deleted file mode 100644 index dd0e3adc8ae5..000000000000 --- a/homeassistant/components/modbus_connection/config_flow.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Config flow for the Modbus Connection integration.""" - -from typing import Any, override - -from modbus_connection import ModbusError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.helpers.selector import ( - SelectSelector, - SelectSelectorConfig, - SelectSelectorMode, - SerialPortSelector, -) - -from . import _async_open -from .const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DEFAULT_BAUDRATE, - DEFAULT_BYTESIZE, - DEFAULT_PARITY, - DEFAULT_PORT, - DEFAULT_STOPBITS, - DOMAIN, -) - -STEP_MODBUS_TCP = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=65535) - ), - } -) - -# SerialPortSelector lists local serial ports and network serial proxies. -STEP_SERIAL = vol.Schema( - { - vol.Required(CONF_DEVICE): SerialPortSelector(), - vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Required(CONF_PARITY, default=DEFAULT_PARITY): SelectSelector( - SelectSelectorConfig( - options=["n", "e", "o"], - translation_key="parity", - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Required(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In([1, 2]), - vol.Required(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([7, 8]), - } -) - - -class ModbusConnectionConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Modbus Connection.""" - - VERSION = 1 - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Let the user choose the transport.""" - return self.async_show_menu( - step_id="user", - menu_options=["modbus_tcp", "serial"], - ) - - async def async_step_modbus_tcp( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure a Modbus TCP / RTU-over-TCP connection.""" - errors: dict[str, str] = {} - if user_input is not None: - data = {CONF_TYPE: CONNECTION_TCP, **user_input} - # Dedupe before opening: most Modbus devices reject a second client. - self._async_abort_entries_match(data) - if not (errors := await self._async_validate(data)): - return self.async_create_entry( - title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data - ) - return self.async_show_form( - step_id="modbus_tcp", data_schema=STEP_MODBUS_TCP, errors=errors - ) - - async def async_step_serial( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure a Modbus serial (RTU) connection, incl. network serial proxies.""" - errors: dict[str, str] = {} - if user_input is not None: - data = { - CONF_TYPE: CONNECTION_SERIAL, - **user_input, - # Store the uppercase parity code the connection expects. - CONF_PARITY: user_input[CONF_PARITY].upper(), - } - # A serial link is identified by its device path alone, regardless of - # baud rate and other line settings. - self._async_abort_entries_match( - {CONF_TYPE: CONNECTION_SERIAL, CONF_DEVICE: data[CONF_DEVICE]} - ) - if not (errors := await self._async_validate(data)): - return self.async_create_entry(title=data[CONF_DEVICE], data=data) - return self.async_show_form( - step_id="serial", data_schema=STEP_SERIAL, errors=errors - ) - - async def _async_validate(self, data: dict[str, Any]) -> dict[str, str]: - """Validate by actually opening the connection; return form errors.""" - try: - connection = await _async_open(data) - except ModbusError: - if data[CONF_TYPE] == CONNECTION_SERIAL: - return {"base": "cannot_open_serial_port"} - return {"base": "cannot_connect"} - await connection.close() - return {} diff --git a/homeassistant/components/modbus_connection/const.py b/homeassistant/components/modbus_connection/const.py deleted file mode 100644 index 369ecc4c5a09..000000000000 --- a/homeassistant/components/modbus_connection/const.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Constants for the Modbus Connection integration.""" - -from typing import Final - -DOMAIN: Final = "modbus_connection" - -# Transport selection (stored under homeassistant.const.CONF_TYPE). -CONNECTION_TCP: Final = "tcp" -CONNECTION_SERIAL: Final = "serial" - -# Serial-only options. -CONF_BAUDRATE: Final = "baudrate" -CONF_BYTESIZE: Final = "bytesize" -CONF_PARITY: Final = "parity" -CONF_STOPBITS: Final = "stopbits" - -DEFAULT_PORT: Final = 502 -DEFAULT_BAUDRATE: Final = 9600 -DEFAULT_BYTESIZE: Final = 8 -DEFAULT_PARITY: Final = "n" -DEFAULT_STOPBITS: Final = 1 diff --git a/homeassistant/components/modbus_connection/exceptions.py b/homeassistant/components/modbus_connection/exceptions.py deleted file mode 100644 index 5c1ee68134b9..000000000000 --- a/homeassistant/components/modbus_connection/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Exceptions for the Modbus Connection integration.""" - -from modbus_connection import ModbusError - -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - - -class ConnectionNotReady(ConfigEntryNotReady, ModbusError): - """The shared Modbus connection is missing or not loaded. - - Raised by ``async_get_unit``. It is a ``ConfigEntryNotReady`` so a consumer - integration can let it propagate from its own ``async_setup_entry`` to get - Home Assistant's setup-retry behaviour, and a ``ModbusError`` so it is also - catchable with the library's error type. - """ - - def __init__(self, connection_entry_id: str) -> None: - """Initialize the error.""" - super().__init__( - translation_domain=DOMAIN, - translation_key="connection_not_ready", - ) - self.connection_entry_id = connection_entry_id diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json deleted file mode 100644 index a3f132e4e6d9..000000000000 --- a/homeassistant/components/modbus_connection/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "modbus_connection", - "name": "Modbus Connection", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "dependencies": ["usb"], - "documentation": "https://www.home-assistant.io/integrations/modbus_connection", - "integration_type": "hub", - "iot_class": "local_polling", - "loggers": ["modbus_connection", "tmodbus"], - "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.4.1"] -} diff --git a/homeassistant/components/modbus_connection/quality_scale.yaml b/homeassistant/components/modbus_connection/quality_scale.yaml deleted file mode 100644 index 6eb47cc6c8b6..000000000000 --- a/homeassistant/components/modbus_connection/quality_scale.yaml +++ /dev/null @@ -1,119 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: This integration does not register any service actions. - appropriate-polling: - status: exempt - comment: | - This integration does not poll. It owns a connection and hands out units; - consumer integrations poll through their own coordinators. - brands: done - common-modules: done - config-flow: done - config-flow-test-coverage: done - dependency-transparency: done - docs-actions: - status: exempt - comment: This integration does not register any service actions. - docs-conditions: - status: exempt - comment: This integration does not provide any conditions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - docs-triggers: - status: exempt - comment: This integration does not provide any triggers. - entity-event-setup: - status: exempt - comment: This integration provides no entities. - entity-unique-id: - status: exempt - comment: This integration provides no entities. - has-entity-name: - status: exempt - comment: This integration provides no entities. - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done - # Silver - action-exceptions: - status: exempt - comment: This integration does not register any service actions. - config-entry-unloading: done - docs-configuration-parameters: done - docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: This integration provides no entities. - integration-owner: done - log-when-unavailable: - status: exempt - comment: | - This integration provides no entities; availability is surfaced to - consumers via on_connection_lost and failing reads. - parallel-updates: - status: exempt - comment: This integration provides no entity platforms. - reauthentication-flow: - status: exempt - comment: A Modbus link has no authentication. - test-coverage: done - # Gold - devices: - status: exempt - comment: This integration provides connections, not devices or entities. - diagnostics: todo - discovery: - status: exempt - comment: Modbus links are not discoverable. - discovery-update-info: - status: exempt - comment: Modbus links are not discoverable. - docs-data-update: - status: exempt - comment: This integration provides no entities to update. - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: - status: exempt - comment: This integration is a connection provider, not a device integration. - docs-supported-functions: - status: exempt - comment: This integration provides no entities. - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: - status: exempt - comment: This integration provides no devices. - entity-category: - status: exempt - comment: This integration provides no entities. - entity-device-class: - status: exempt - comment: This integration provides no entities. - entity-disabled-by-default: - status: exempt - comment: This integration provides no entities. - entity-translations: - status: exempt - comment: This integration provides no entities. - exception-translations: todo - icon-translations: - status: exempt - comment: This integration provides no entities. - reconfiguration-flow: todo - repair-issues: - status: exempt - comment: No repairable issues are raised. - stale-devices: - status: exempt - comment: This integration provides no devices. - # Platinum - async-dependency: done - inject-websession: - status: exempt - comment: This integration talks Modbus, not HTTP. - strict-typing: done diff --git a/homeassistant/components/modbus_connection/strings.json b/homeassistant/components/modbus_connection/strings.json deleted file mode 100644 index a71d59af83bf..000000000000 --- a/homeassistant/components/modbus_connection/strings.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_open_serial_port": "Failed to open the serial port" - }, - "step": { - "modbus_tcp": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" - }, - "data_description": { - "host": "The hostname or IP address of the Modbus gateway or device.", - "port": "The TCP port the Modbus gateway listens on (default 502)." - }, - "title": "Modbus TCP" - }, - "serial": { - "data": { - "baudrate": "Baud rate", - "bytesize": "Byte size", - "device": "[%key:common::config_flow::data::device%]", - "parity": "Parity", - "stopbits": "Stop bits" - }, - "data_description": { - "baudrate": "The serial baud rate the device communicates at.", - "bytesize": "The number of data bits.", - "device": "The serial port the Modbus device is connected to, e.g. /dev/ttyUSB0.", - "parity": "The serial parity.", - "stopbits": "The number of stop bits." - }, - "title": "Serial connection" - }, - "user": { - "description": "How is the Modbus network connected?", - "menu_options": { - "modbus_tcp": "Modbus TCP", - "serial": "Serial (including serial proxies and networked connections)" - } - } - } - }, - "exceptions": { - "connection_not_ready": { - "message": "Modbus connection not ready" - } - }, - "selector": { - "parity": { - "options": { - "e": "Even", - "n": "None", - "o": "Odd" - } - } - } -} diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index d60b5f0c696d..77bbb507849f 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -37,7 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to the humidity sensor's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/moon/const.py b/homeassistant/components/moon/const.py index 3e926b4ff3e8..f51f80431804 100644 --- a/homeassistant/components/moon/const.py +++ b/homeassistant/components/moon/const.py @@ -8,3 +8,5 @@ DOMAIN: Final = "moon" PLATFORMS: Final = [Platform.SENSOR] DEFAULT_NAME: Final = "Moon" + +CONF_PHASE: Final = "phase" diff --git a/homeassistant/components/moon/helpers.py b/homeassistant/components/moon/helpers.py new file mode 100644 index 000000000000..dbf3b7907b3d --- /dev/null +++ b/homeassistant/components/moon/helpers.py @@ -0,0 +1,48 @@ +"""Helpers for moon phases.""" + +from astral import moon + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +STATE_FIRST_QUARTER = "first_quarter" +STATE_FULL_MOON = "full_moon" +STATE_LAST_QUARTER = "last_quarter" +STATE_NEW_MOON = "new_moon" +STATE_WANING_CRESCENT = "waning_crescent" +STATE_WANING_GIBBOUS = "waning_gibbous" +STATE_WAXING_CRESCENT = "waxing_crescent" +STATE_WAXING_GIBBOUS = "waxing_gibbous" + +# The eight moon phases in chronological order (new moon to waning crescent). +MOON_PHASES: tuple[str, ...] = ( + STATE_NEW_MOON, + STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, + STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, +) + + +@callback +def moon_phase() -> str: + """Return the current moon phase.""" + value: float = moon.phase(dt_util.now().date()) + if value < 0.5 or value > 27.5: + return STATE_NEW_MOON + if value < 6.5: + return STATE_WAXING_CRESCENT + if value < 7.5: + return STATE_FIRST_QUARTER + if value < 13.5: + return STATE_WAXING_GIBBOUS + if value < 14.5: + return STATE_FULL_MOON + if value < 20.5: + return STATE_WANING_GIBBOUS + if value < 21.5: + return STATE_LAST_QUARTER + return STATE_WANING_CRESCENT diff --git a/homeassistant/components/moon/icons.json b/homeassistant/components/moon/icons.json index 77c578c8f0d8..288925f28be3 100644 --- a/homeassistant/components/moon/icons.json +++ b/homeassistant/components/moon/icons.json @@ -15,5 +15,10 @@ } } } + }, + "triggers": { + "phase_changed": { + "trigger": "mdi:moon-waning-crescent" + } } } diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 3f7f25eb8149..c20a0a392dc6 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -1,24 +1,13 @@ """Support for tracking the moon phases.""" -from astral import moon - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util from .const import DOMAIN - -STATE_FIRST_QUARTER = "first_quarter" -STATE_FULL_MOON = "full_moon" -STATE_LAST_QUARTER = "last_quarter" -STATE_NEW_MOON = "new_moon" -STATE_WANING_CRESCENT = "waning_crescent" -STATE_WANING_GIBBOUS = "waning_gibbous" -STATE_WAXING_CRESCENT = "waxing_crescent" -STATE_WAXING_GIBBOUS = "waxing_gibbous" +from .helpers import MOON_PHASES, moon_phase async def async_setup_entry( @@ -35,16 +24,7 @@ class MoonSensorEntity(SensorEntity): _attr_has_entity_name = True _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [ - STATE_NEW_MOON, - STATE_WAXING_CRESCENT, - STATE_FIRST_QUARTER, - STATE_WAXING_GIBBOUS, - STATE_FULL_MOON, - STATE_WANING_GIBBOUS, - STATE_LAST_QUARTER, - STATE_WANING_CRESCENT, - ] + _attr_options = list(MOON_PHASES) _attr_translation_key = "phase" def __init__(self, entry: ConfigEntry) -> None: @@ -58,22 +38,4 @@ class MoonSensorEntity(SensorEntity): async def async_update(self) -> None: """Get the time and updates the states.""" - today = dt_util.now().date() - state = moon.phase(today) - - if state < 0.5 or state > 27.5: - self._attr_native_value = STATE_NEW_MOON - elif state < 6.5: - self._attr_native_value = STATE_WAXING_CRESCENT - elif state < 7.5: - self._attr_native_value = STATE_FIRST_QUARTER - elif state < 13.5: - self._attr_native_value = STATE_WAXING_GIBBOUS - elif state < 14.5: - self._attr_native_value = STATE_FULL_MOON - elif state < 20.5: - self._attr_native_value = STATE_WANING_GIBBOUS - elif state < 21.5: - self._attr_native_value = STATE_LAST_QUARTER - else: - self._attr_native_value = STATE_WANING_CRESCENT + self._attr_native_value = moon_phase() diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index 8048f344c7b1..65baaed8766a 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -37,5 +37,32 @@ } } }, - "title": "Moon" + "selector": { + "phase": { + "options": { + "any": "Any", + "first_quarter": "[%key:component::moon::entity::sensor::phase::state::first_quarter%]", + "full_moon": "[%key:component::moon::entity::sensor::phase::state::full_moon%]", + "last_quarter": "[%key:component::moon::entity::sensor::phase::state::last_quarter%]", + "new_moon": "[%key:component::moon::entity::sensor::phase::state::new_moon%]", + "waning_crescent": "[%key:component::moon::entity::sensor::phase::state::waning_crescent%]", + "waning_gibbous": "[%key:component::moon::entity::sensor::phase::state::waning_gibbous%]", + "waxing_crescent": "[%key:component::moon::entity::sensor::phase::state::waxing_crescent%]", + "waxing_gibbous": "[%key:component::moon::entity::sensor::phase::state::waxing_gibbous%]" + } + } + }, + "title": "Moon", + "triggers": { + "phase_changed": { + "description": "Triggers when the moon enters a new phase.", + "fields": { + "phase": { + "description": "Limit the trigger to a specific moon phase, or leave as Any to trigger on every phase change.", + "name": "Phase" + } + }, + "name": "Moon phase changed" + } + } } diff --git a/homeassistant/components/moon/trigger.py b/homeassistant/components/moon/trigger.py new file mode 100644 index 000000000000..174436020f9a --- /dev/null +++ b/homeassistant/components/moon/trigger.py @@ -0,0 +1,88 @@ +"""Provides triggers for the moon.""" + +from datetime import datetime +from typing import cast, override + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_change +from homeassistant.helpers.trigger import ( + Trigger, + TriggerActionRunner, + TriggerConfig, + TriggerNotTriggeredReporter, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PHASE +from .helpers import MOON_PHASES, moon_phase + +PHASE_ANY = "any" + +_PHASE_CHANGED_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default=dict): { + vol.Optional(CONF_PHASE, default=PHASE_ANY): vol.In( + [PHASE_ANY, *MOON_PHASES] + ), + } + } +) + + +class MoonPhaseChangedTrigger(Trigger): + """Trigger that fires when the moon enters a new phase.""" + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, _PHASE_CHANGED_TRIGGER_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + options = config.options or {} + self._phase: str = options[CONF_PHASE] + + @override + async def async_attach_runner( + self, + run_action: TriggerActionRunner, + did_not_trigger: TriggerNotTriggeredReporter | None = None, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + last_phase = moon_phase() + + @callback + def check_phase(_now: datetime) -> None: + nonlocal last_phase + current_phase = moon_phase() + if current_phase == last_phase: + return + previous_phase = last_phase + last_phase = current_phase + if self._phase in (PHASE_ANY, current_phase): + run_action( + {"phase": current_phase, "previous_phase": previous_phase}, + "moon phase changed", + ) + + # The binned phase can only change when the local date rolls over. + return async_track_time_change( + self._hass, check_phase, hour=0, minute=0, second=0 + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "phase_changed": MoonPhaseChangedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for the moon.""" + return TRIGGERS diff --git a/homeassistant/components/moon/triggers.yaml b/homeassistant/components/moon/triggers.yaml new file mode 100644 index 000000000000..7a6457d452e3 --- /dev/null +++ b/homeassistant/components/moon/triggers.yaml @@ -0,0 +1,18 @@ +phase_changed: + fields: + phase: + required: true + default: any + selector: + select: + translation_key: phase + options: + - any + - new_moon + - waxing_crescent + - first_quarter + - waxing_gibbous + - full_moon + - waning_gibbous + - last_quarter + - waning_crescent diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 0efba71bbf72..8cf181e7699d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -7,16 +7,18 @@ from typing import TYPE_CHECKING, Any, override import voluptuous as vol from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import ( + SourceType, + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_HOME, STATE_NOT_HOME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -162,16 +164,18 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ) -> None: """Extract the location from the extra state attributes.""" if ( - ATTR_LATITUDE in extra_state_attributes - or ATTR_LONGITUDE in extra_state_attributes + EntityStateAttribute.LATITUDE in extra_state_attributes + or EntityStateAttribute.LONGITUDE in extra_state_attributes ): latitude: float | None longitude: float | None gps_accuracy: float if isinstance( - latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + latitude := extra_state_attributes.get(EntityStateAttribute.LATITUDE), + (int, float), ) and isinstance( - longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + longitude := extra_state_attributes.get(EntityStateAttribute.LONGITUDE), + (int, float), ): self._attr_latitude = latitude self._attr_longitude = longitude @@ -187,9 +191,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): extra_state_attributes, ) - if ATTR_GPS_ACCURACY in extra_state_attributes: + if TrackerEntityStateAttribute.GPS_ACCURACY in extra_state_attributes: if isinstance( - gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + gps_accuracy := extra_state_attributes[ + TrackerEntityStateAttribute.GPS_ACCURACY + ], (int, float), ): self._attr_location_accuracy = gps_accuracy @@ -210,5 +216,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._attr_extra_state_attributes = { attribute: value for attribute, value in extra_state_attributes.items() - if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + if attribute + not in { + TrackerEntityStateAttribute.GPS_ACCURACY, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, + } } diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 68d4b2fb9c7c..5ab4861201f4 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -5,12 +5,7 @@ from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_PASSWORD, - CONF_USERNAME, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -18,7 +13,10 @@ from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} -REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} +REDACT_STATE_DEVICE_TRACKER = { + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, +} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index f11d73a6af63..17f714a45ec2 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -248,9 +248,7 @@ async def async_setup_entry( # noqa: C901 for device in dev_entries: for identifier in device.identifiers: if identifier[0] == DOMAIN and identifier[1] not in player_ids: - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) return True diff --git a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py index 7061bdc83f60..6d6b2a7c1e98 100644 --- a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -109,6 +109,7 @@ class NSBinarySensor(CoordinatorEntity[NSDataUpdateCoordinator], BinarySensorEnt name=coordinator.name, manufacturer=INTEGRATION_TITLE, model=ROUTE_MODEL, + entry_type=DeviceEntryType.SERVICE, ) @property diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index c88ef824aa11..7eef35544195 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -202,6 +202,7 @@ class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity): name=coordinator.name, manufacturer=INTEGRATION_TITLE, model=ROUTE_MODEL, + entry_type=DeviceEntryType.SERVICE, ) @property diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fec919b72372..174b8686a4aa 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -236,10 +236,7 @@ class SignalUpdateCallback: if device_id in devices: continue _LOGGER.info("Removing stale device entry '%s'", device_id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=self._config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index 3273023e8941..a2f41356e1ce 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ async def async_setup_entry( ) -class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): +class NetatmoCoverPreferredPositionButton(NetatmoReachabilityEntity, ButtonEntity): """Representation of a Netatmo cover preferred position button device.""" _attr_configuration_url = CONF_URL_CONTROL @@ -69,7 +69,7 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - # No state to update for button + self.async_write_ha_state() @override async def async_press(self) -> None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index a05558a975d0..9ae69c6a2435 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -284,6 +284,8 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self.device.events ) + self.async_write_ha_state() + def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" events = {} diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 177ef99a7efe..0b5fea41517a 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -290,6 +290,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): elif self._attr_preset_mode in [PRESET_SCHEDULE, PRESET_HOME]: self.async_update_callback() self.data_handler.async_force_update(self._signal_name) + return self.async_write_ha_state() return @@ -325,7 +326,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_SCHEDULE] self.async_update_callback() - self.async_write_ha_state() return @property @@ -414,15 +414,16 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): @override def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" - return bool(self._connected) + return super().available and bool(self._connected) @callback @override def async_update_callback(self) -> None: """Update the entity's state.""" if not self.device.reachable: - if self.available: + if self._connected: self._connected = False + self.async_write_ha_state() return self._connected = True @@ -458,6 +459,8 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._boilerstatus = module.boiler_status break + self.async_write_ha_state() + async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None diff --git a/homeassistant/components/netatmo/coordinator.py b/homeassistant/components/netatmo/coordinator.py index db8523bd5960..34eb67f3a7ce 100644 --- a/homeassistant/components/netatmo/coordinator.py +++ b/homeassistant/components/netatmo/coordinator.py @@ -135,6 +135,7 @@ class NetatmoPublisher: subscriptions: set[CALLBACK_TYPE | None] method: str kwargs: dict + available: bool = True class NetatmoDataHandler: @@ -254,19 +255,29 @@ class NetatmoDataHandler: **self.publisher[signal_name].kwargs ) - except (pyatmo.NoDeviceError, pyatmo.ApiError) as err: + except ( + pyatmo.NoDeviceError, + pyatmo.ApiError, + TimeoutError, + aiohttp.ClientConnectorError, + ) as err: _LOGGER.debug(err) has_error = True - except (TimeoutError, aiohttp.ClientConnectorError) as err: - _LOGGER.debug(err) - return True + self.publisher[signal_name].available = not has_error + self._notify_subscribers(signal_name) + return has_error + def _notify_subscribers(self, signal_name: str) -> None: + """Notify all subscribers of a publisher to update their state.""" for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() - return has_error + def is_signal_available(self, signal_name: str) -> bool: + """Return whether the last fetch for a publisher succeeded.""" + publisher = self.publisher.get(signal_name) + return publisher is None or publisher.available async def subscribe( self, diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 089964e2ab1a..82c97c0c45c6 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class NetatmoCover(NetatmoModuleEntity, CoverEntity): +class NetatmoCover(NetatmoReachabilityEntity, CoverEntity): """Representation of a Netatmo cover device.""" _attr_supported_features = ( @@ -105,5 +105,7 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_closed = self.device.current_position == 0 - self._attr_current_cover_position = self.device.current_position + if self.device.reachable is not False: + self._attr_is_closed = self.device.current_position == 0 + self._attr_current_cover_position = self.device.current_position + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 97a378c203ac..ae301cb06fc9 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -35,6 +35,15 @@ class NetatmoBaseEntity(Entity): self._publishers: list[dict[str, Any]] = [] self._attr_extra_state_attributes = {} + @property + @override + def available(self) -> bool: + """Return True if the underlying data publishers are reachable.""" + return super().available and all( + self.data_handler.is_signal_available(publisher[SIGNAL_NAME]) + for publisher in self._publishers + ) + @override async def async_added_to_hass(self) -> None: """Entity created.""" @@ -174,6 +183,16 @@ class NetatmoModuleEntity(NetatmoDeviceEntity): return self.device.device_type +class NetatmoReachabilityEntity(NetatmoModuleEntity): + """Module entity that is unavailable when its device is unreachable.""" + + @property + @override + def available(self) -> bool: + """Return True unless the device explicitly reports as unreachable.""" + return super().available and self.device.reachable is not False + + class NetatmoWeatherModuleEntity(NetatmoModuleEntity): """Netatmo weather module entity base class.""" diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 6505eeda9eaf..0e4a4eb828a3 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class NetatmoFan(NetatmoModuleEntity, FanEntity): +class NetatmoFan(NetatmoReachabilityEntity, FanEntity): """Representation of a Netatmo fan.""" _attr_preset_modes = ["slow", "fast"] @@ -78,7 +78,9 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if self.device.fan_speed is None: - self._attr_preset_mode = None - return - self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + if self.device.reachable is not False: + if self.device.fan_speed is None: + self._attr_preset_mode = None + else: + self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index d132b0f75876..2e84133e1707 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -20,7 +20,7 @@ from .const import ( NETATMO_CREATE_LIGHT, ) from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoModuleEntity, NetatmoReachabilityEntity _LOGGER = logging.getLogger(__name__) @@ -124,7 +124,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): @override def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" - return bool(self.data_handler.webhook) + return super().available and bool(self.data_handler.webhook) @override async def async_turn_on(self, **kwargs: Any) -> None: @@ -143,9 +143,10 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): def async_update_callback(self) -> None: """Update the entity's state.""" self._attr_is_on = bool(self.device.floodlight == "on") + self.async_write_ha_state() -class NetatmoLight(NetatmoModuleEntity, LightEntity): +class NetatmoLight(NetatmoReachabilityEntity, LightEntity): """Representation of a dimmable light by Legrand/BTicino.""" _attr_name = None @@ -200,10 +201,12 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on is True + if self.device.reachable is not False: + self._attr_is_on = self.device.on is True - if (brightness := self.device.brightness) is not None: - # Netatmo uses a range of [0, 100] to control brightness - self._attr_brightness = round(brightness * 2.55) - else: - self._attr_brightness = None + if (brightness := self.device.brightness) is not None: + # Netatmo uses a range of [0, 100] to control brightness + self._attr_brightness = round(brightness * 2.55) + else: + self._attr_brightness = None + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml index 9896b2e2d8f4..d69785e45207 100644 --- a/homeassistant/components/netatmo/quality_scale.yaml +++ b/homeassistant/components/netatmo/quality_scale.yaml @@ -34,7 +34,7 @@ rules: config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: done diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 78492fecd9a9..3527e8ae7dff 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -132,3 +132,4 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index b96292b245df..043b7cfb9842 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -62,6 +62,7 @@ from .coordinator import ( ) from .entity import ( NetatmoBaseEntity, + NetatmoDeviceEntity, NetatmoModuleEntity, NetatmoRoomEntity, NetatmoWeatherModuleEntity, @@ -631,7 +632,25 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): +class NetatmoLegacyReachableSensor(NetatmoDeviceEntity, SensorEntity): + """Sensor mixin that goes unavailable, keeping its last value, when unreachable.""" + + @callback + def _async_set_unavailable_if_unreachable(self) -> bool: + """Set the entity unavailable and write state when the device is unreachable. + + Returns True when the device is unreachable so callers return early. + """ + device = cast("pyatmo.Module | pyatmo.Room", self.device) + if device.reachable: + return False + if self.available: + self._attr_available = False + self.async_write_ha_state() + return True + + +class NetatmoBaseSensor(NetatmoModuleEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -666,16 +685,11 @@ class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): """Update the entity's state (the legacy way).""" # Keep the last known value for these legacy sensors when the device is # unreachable to preserve the historical behavior expected by existing entities. - if not self.device.reachable: - if self.available: - self._attr_available = False - return - - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True - self._attr_native_value = state + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -700,7 +714,7 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, NetatmoBaseSensor): @override def available(self) -> bool: """Return True if entity is available.""" - return ( + return super().available and ( self.device.reachable or getattr( self.device, @@ -792,9 +806,7 @@ class NetatmoClimateBatterySensor(NetatmoLegacySensor): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if not self.device.reachable: - if self.available: - self._attr_available = False + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True @@ -861,7 +873,7 @@ class NetatmoSensor(NetatmoBaseSensor): self.async_write_ha_state() -class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): +class NetatmoRoomSensor(NetatmoRoomEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo room sensor.""" entity_description: NetatmoSensorEntityDescription @@ -893,10 +905,11 @@ class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return - self._attr_native_value = state + self._attr_available = True + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -976,6 +989,17 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): self._signal_name = f"{PUBLIC}-{area.uuid}" self._mode = area.mode self._show_on_map = area.show_on_map + self._publishers = [ + { + "name": PUBLIC, + "lat_ne": area.lat_ne, + "lon_ne": area.lon_ne, + "lat_sw": area.lat_sw, + "lon_sw": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ] await self.data_handler.subscribe( PUBLIC, self._signal_name, @@ -1001,6 +1025,7 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): ) self._attr_available = False + self.async_write_ha_state() return if values := [x for x in data.values() if x is not None]: diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 357edb673685..8a07e7951dcd 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ async def async_setup_entry( ) -class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): +class NetatmoSwitch(NetatmoReachabilityEntity, SwitchEntity): """Representation of a Netatmo switch device.""" _attr_name = None @@ -70,7 +70,9 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on + if self.device.reachable is not False: + self._attr_is_on = self.device.on + self.async_write_ha_state() @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index afc32d4c5be6..2212644bce60 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -94,9 +94,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> if device_entry.via_device_id is None: router_id = device_entry.id continue # do not remove the router itself - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Remove entities that are no longer tracked entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json index a76da88914d6..7dee6332f583 100644 --- a/homeassistant/components/network/manifest.json +++ b/homeassistant/components/network/manifest.json @@ -2,7 +2,6 @@ "domain": "network", "name": "Network Configuration", "codeowners": ["@home-assistant/core"], - "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/network", "integration_type": "system", "iot_class": "local_push", diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index daf5611f0424..2529610da6c1 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -2,7 +2,7 @@ import logging -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b try: hub = await _connect(stored_ip) - except OSError as err: + except PynoboConnectionError as err: # Stored IP may be stale - try UDP rediscovery to pick up a new # DHCP lease (or a hub that's been moved). discovered = await nobo.async_discover_hubs(serial=serial) @@ -66,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b new_ip, _ = next(iter(discovered)) try: hub = await _connect(new_ip) - except OSError as rediscover_err: + except PynoboConnectionError as rediscover_err: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", @@ -116,6 +116,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + @callback + def _cleanup_devices(_hub: nobo) -> None: + """Remove devices for zones and components no longer on the hub.""" + if not hub.connected: + # While disconnected pynobo may hold stale topology; only reconcile + # against a live, fully-synced hub. + return + expected_identifiers = {(DOMAIN, hub.hub_serial)} + expected_identifiers.update( + (DOMAIN, f"{hub.hub_serial}:{zone_id}") for zone_id in hub.zones + ) + expected_identifiers.update((DOMAIN, serial) for serial in hub.components) + # Runs inside pynobo's update-callback dispatch: removing a device + # deregisters its entities' callbacks mid-iteration, which can skip a + # following callback. Safe because a pynobo message carries a single + # topology change, so a removal never coincides with a surviving + # entity's update in the same dispatch. + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if device.identifiers.isdisjoint(expected_identifiers): + device_registry.async_remove_device(device.id) + + _cleanup_devices(hub) + hub.register_callback(_cleanup_devices) + entry.async_on_unload(lambda: hub.deregister_callback(_cleanup_devices)) + await hub.start() return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 2ddd05e1bbd3..aa09b8fba97f 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -56,8 +56,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" - - # Setup connection with hub hub = config_entry.runtime_data override_type = ( @@ -66,8 +64,26 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - # Add zones as entities - async_add_entities(NoboZone(zone_id, hub, override_type) for zone_id in hub.zones) + known_zones: set[str] = set() + + @callback + def _add_zones(_hub: nobo) -> None: + """Add climate entities for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + NoboZone(zone_id, hub, override_type) for zone_id in new_zones + ) + + _add_zones(hub) + hub.register_callback(_add_zones) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_zones)) class NoboZone(NoboBaseEntity, ClimateEntity): diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 8df9940493d3..ecbd23a7d487 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -3,7 +3,7 @@ import ipaddress from typing import TYPE_CHECKING, Any, override -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo import voluptuous as vol from homeassistant.config_entries import ( @@ -313,14 +313,14 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) # pynobo distinguishes the two failure modes: TCP-level errors - # (wrong IP, hub offline, port closed) raise OSError, while a - # successful TCP connection followed by a handshake REJECT + # (wrong IP, hub offline, port closed) raise PynoboConnectionError, + # while a successful TCP connection followed by a handshake REJECT # (serial mismatch) returns False. try: if not await hub.async_connect_hub(ip_address, serial): raise NoboHubConnectError("cannot_connect") return hub.hub_info["name"] - except OSError as err: + except PynoboConnectionError as err: raise NoboHubConnectError("cannot_connect_ip") from err finally: await hub.close() diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py new file mode 100644 index 000000000000..7916848774cd --- /dev/null +++ b/homeassistant/components/nobo_hub/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for Nobø Ecohub.""" + +from typing import Any + +from pynobo import ComponentInfo, nobo + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant + +from . import NoboHubConfigEntry +from .const import ATTR_SERIAL, CONF_SERIAL + +TO_REDACT_ENTRY = {CONF_IP_ADDRESS, CONF_MAC, CONF_SERIAL} +TO_REDACT_HUB = {ATTR_SERIAL} + +_MODEL_FIELDS = ( + "model_id", + "name", + "type", + "has_temp_sensor", + "requires_control_panel", + "supports_comfort", + "supports_eco", +) + + +def _component_to_dict(component: ComponentInfo) -> dict[str, Any]: + model = component["model"] + formatted: dict[str, Any] = dict(component) + formatted["model"] = {field: getattr(model, field, None) for field in _MODEL_FIELDS} + if model.type == nobo.Model.UNKNOWN: + # Unknown models carry the serial number in the name. + formatted["model"]["name"] = REDACTED + return formatted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NoboHubConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hub = entry.runtime_data + return { + "entry_data": async_redact_data(entry.data, TO_REDACT_ENTRY), + "hub_info": async_redact_data(hub.hub_info, TO_REDACT_HUB), + "zones": hub.zones, + "components": async_redact_data( + [_component_to_dict(c) for c in hub.components.values()], + TO_REDACT_HUB, + ), + "week_profiles": hub.week_profiles, + "overrides": hub.overrides, + } diff --git a/homeassistant/components/nobo_hub/icons.json b/homeassistant/components/nobo_hub/icons.json new file mode 100644 index 000000000000..74c20b18fe5b --- /dev/null +++ b/homeassistant/components/nobo_hub/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "select": { + "global_override": { + "default": "mdi:calendar-clock", + "state": { + "away": "mdi:account-arrow-right", + "comfort": "mdi:sofa", + "eco": "mdi:leaf" + } + }, + "week_profile": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index a098ec5a6607..3350742c38d5 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 1812cab9b10f..fb94595ed48f 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -11,7 +11,7 @@ rules: dependency-transparency: done docs-actions: status: exempt - comment: Integration does not register custom actions. + comment: This integration does not register custom actions. docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -48,32 +48,38 @@ rules: status: done comment: > Model name "Nobø Ecohub" under review for rename to "Nobø Hub". - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: + status: exempt + comment: > + All entities are primary controls or measurements; none are configuration + or diagnostic entities that need a non-default entity category. entity-device-class: done - entity-disabled-by-default: todo - entity-translations: todo - exception-translations: todo - icon-translations: todo + entity-disabled-by-default: + status: exempt + comment: This integration has no entities that should be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt - comment: Integration has no repair scenarios. - stale-devices: todo + comment: This integration has no repair scenarios. + stale-devices: done # Platinum async-dependency: done inject-websession: status: exempt - comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used. + comment: This integration uses a local TCP socket (via pynobo); no HTTP client is used. strict-typing: todo diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 9c8313ebdfc5..85ad51e78e04 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -32,8 +32,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" - - # Setup connection with hub hub = config_entry.runtime_data override_type = ( @@ -42,11 +40,28 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - entities: list[SelectEntity] = [ - NoboProfileSelector(zone_id, hub) for zone_id in hub.zones - ] - entities.append(NoboGlobalSelector(hub, override_type)) - async_add_entities(entities, True) + async_add_entities([NoboGlobalSelector(hub, override_type)], True) + + known_zones: set[str] = set() + + @callback + def _add_profiles(_hub: nobo) -> None: + """Add week-profile selectors for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + (NoboProfileSelector(zone_id, hub) for zone_id in new_zones), True + ) + + _add_profiles(hub) + hub.register_callback(_add_profiles) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_profiles)) class NoboGlobalSelector(NoboBaseEntity, SelectEntity): diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 8ebea0b63419..371fa96e6823 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -28,15 +28,32 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" - - # Setup connection with hub hub = config_entry.runtime_data - async_add_entities( - NoboTemperatureSensor(component["serial"], hub) - for component in hub.components.values() - if component[ATTR_MODEL].has_temp_sensor - ) + known_components: set[str] = set() + + @callback + def _add_sensors(_hub: nobo) -> None: + """Add temperature sensors for components added to the hub.""" + if hub.connected: + # Forget components no longer on the hub so a removed-then-re-added + # component is detected as new again. Skip while disconnected: a + # stale/empty snapshot would drop live components and cause + # duplicate re-adds on reconnect. + known_components.intersection_update(hub.components) + new_components = [ + serial + for serial, component in hub.components.items() + if component[ATTR_MODEL].has_temp_sensor and serial not in known_components + ] + known_components.update(new_components) + async_add_entities( + NoboTemperatureSensor(serial, hub) for serial in new_components + ) + + _add_sensors(hub) + hub.register_callback(_add_sensors) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_sensors)) class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 2b744e01d0da..6937f9f82022 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -66,6 +66,4 @@ async def cleanup_device( continue LOGGER.debug("Removing device %s", entry.name) - device_reg.async_update_device( - entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(entry.id) diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 1bda5ab4e4d5..06b965ae3cb6 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -41,7 +43,7 @@ async def async_get_config_entry_diagnostics( assert hass_device is not None data["device"] = { - **attr.asdict(hass_device), + **device_entry_as_dict(hass_device), "entities": {}, } @@ -61,10 +63,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), + **entity_dict, "state": state_dict, } diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 16717b66d4d2..7ee31e4e8662 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.util.ssl import get_default_context from .const import ( @@ -192,7 +192,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -202,20 +202,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index f34f88c2cae7..77ccb98d9a35 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -36,7 +36,7 @@ from homeassistant.helpers import ( selector, ) from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( CONF_CHAT_MODEL, @@ -386,7 +386,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -396,20 +396,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c773d3399695..05ed4fe18f5f 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -48,6 +48,7 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -77,6 +78,7 @@ from .const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -421,6 +423,18 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) + if model.startswith("gpt-5.6"): + step_schema.update( + { + vol.Optional( + CONF_PRO_MODE, + default=RECOMMENDED_PRO_MODE, + ): bool, + } + ) + elif CONF_PRO_MODE in options: + options.pop(CONF_PRO_MODE) + if model.startswith("gpt-5"): step_schema.update( { @@ -592,6 +606,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { + "gpt-5.6": ["none", "low", "medium", "high", "xhigh", "max"], ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 5236a0d9f53a..6f76455b0c02 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -20,6 +20,7 @@ CONF_IMAGE_MODEL = "image_model" CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" +CONF_PRO_MODE = "pro_mode" CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" @@ -41,6 +42,7 @@ RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_IMAGE_MODEL = "gpt-image-2" RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_PRO_MODE = False RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_STORE_RESPONSES = False RECOMMENDED_REASONING_SUMMARY = "auto" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 5ac94beb19a5..5fa447e9b925 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -73,6 +73,7 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, @@ -93,6 +94,7 @@ from .const import ( RECOMMENDED_CHAT_MODEL, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -497,7 +499,7 @@ class OpenAIBaseLLMEntity(Entity): entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _async_handle_chat_log( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, @@ -528,11 +530,16 @@ class OpenAIBaseLLMEntity(Entity): if not model_args["model"].startswith("gpt-5-pro") else "high", # GPT-5 pro only supports reasoning.effort: high } + reasoning_summary = options.get( CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY ) if reasoning_summary != "off": reasoning["summary"] = reasoning_summary + + if options.get(CONF_PRO_MODE, RECOMMENDED_PRO_MODE): + reasoning["mode"] = "pro" + model_args["reasoning"] = reasoning model_args["include"] = ["reasoning.encrypted_content"] diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 03637baf4868..6b7d21ea44c2 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -71,6 +71,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", @@ -82,6 +83,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", @@ -138,6 +140,7 @@ "code_interpreter": "Enable code interpreter tool", "image_model": "Image generation model", "inline_citations": "Include links in web search results", + "pro_mode": "Pro mode", "reasoning_effort": "Reasoning effort", "reasoning_summary": "Reasoning summary", "search_context_size": "Search context size", @@ -149,6 +152,7 @@ "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "image_model": "The model to use when generating images", "inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations", + "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", "search_context_size": "High level guidance for the amount of context window space to use for the search", @@ -233,6 +237,7 @@ "options": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", + "max": "Max", "medium": "[%key:common::state::medium%]", "minimal": "Minimal", "none": "None", diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 3bbaabf3b0f7..323b2ae28867 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -287,9 +287,7 @@ async def async_setup_entry( if entity_entry.config_entry_id != entry.entry_id: continue entity_registry.async_remove(entity_entry.entity_id) - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Prune sensor tracking for accounts that are no longer present if created_sensors: diff --git a/homeassistant/components/oralb/icons.json b/homeassistant/components/oralb/icons.json index 7f28dede4ae1..a3b464edc1d2 100644 --- a/homeassistant/components/oralb/icons.json +++ b/homeassistant/components/oralb/icons.json @@ -37,8 +37,7 @@ "sector_1": "mdi:circle-slice-2", "sector_2": "mdi:circle-slice-4", "sector_3": "mdi:circle-slice-6", - "sector_4": "mdi:circle-slice-8", - "success": "mdi:check-circle-outline" + "sector_4": "mdi:circle-slice-8" } }, "toothbrush_state": { diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a15ea81e9067..7bc928e46e5b 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["oralb_ble"], - "requirements": ["oralb-ble==1.1.0"] + "requirements": ["oralb-ble==1.1.1"] } diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 286defcf35f8..28fb2be64c74 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -3,13 +3,7 @@ from typing import override from oralb_ble import OralBSensor, SensorUpdate -from oralb_ble.parser import ( - IO_SERIES_MODES, - PRESSURE, - SECTOR_MAP, - SMART_SERIES_MODES, - STATES, -) +from oralb_ble.parser import IO_SERIES_MODES, PRESSURE, SMART_SERIES_MODES, STATES from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, @@ -46,7 +40,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { key=OralBSensor.SECTOR, translation_key="sector", entity_category=EntityCategory.DIAGNOSTIC, - options=[v.replace(" ", "_") for v in set(SECTOR_MAP.values()) | {"no_sector"}], + options=["no_sector", *(f"sector_{sector}" for sector in range(1, 8))], device_class=SensorDeviceClass.ENUM, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index 2aa29d12f13e..de7e62978a48 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -60,7 +60,9 @@ "sector_2": "Sector 2", "sector_3": "Sector 3", "sector_4": "Sector 4", - "success": "Success" + "sector_5": "Sector 5", + "sector_6": "Sector 6", + "sector_7": "Sector 7" } }, "sector_timer": { diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 4f56034d03c0..e68c9d95b68a 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -57,6 +57,9 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: ( + ValveHeatingTemperatureInterface + ), UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent, } diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index b0cbe6f9c8a8..6748b59fa545 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -119,6 +119,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, } diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 25a872a00e90..34397ff794e2 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -354,8 +354,8 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ # uiClass is Generic (not mapped to cover as this is a Generic device class) OverkizCoverDescription( key=UIWidget.RTS_GENERIC, - open_command=OverkizCommand.OPEN, - close_command=OverkizCommand.CLOSE, + open_command=OverkizCommand.UP, + close_command=OverkizCommand.DOWN, stop_command=OverkizCommand.STOP, ), ## diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 4b843588c16d..0468340432a1 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -14,7 +14,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz[nexity]==2.0.4"], + "requirements": ["pyoverkiz[nexity]==2.1.0"], "zeroconf": [ { "name": "gateway*", diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index b955d2a50a40..a48ac7669b41 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,9 +9,14 @@ LOGGER = logging.getLogger(__package__) REQUESTS = "requests" +ATTR_MEDIA_TYPE = "media_type" +ATTR_QUERY = "query" +ATTR_REQUESTED_BY = "requested_by" +ATTR_SEASONS = "seasons" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" -ATTR_REQUESTED_BY = "requested_by" +ATTR_MEDIA_ID = "media_id" + EVENT_KEY = f"{DOMAIN}_event" diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json index 9b63943f8989..290aa0a976dc 100644 --- a/homeassistant/components/overseerr/icons.json +++ b/homeassistant/components/overseerr/icons.json @@ -32,6 +32,12 @@ "services": { "get_requests": { "service": "mdi:multimedia" + }, + "request_media": { + "service": "mdi:download" + }, + "search_media": { + "service": "mdi:magnify" } } } diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 5354102472ca..9405b21ea6dd 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -1,7 +1,8 @@ """Define services for the Overseerr integration.""" +import ast from dataclasses import asdict -from typing import Any, cast +from typing import Any, Literal, cast from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol @@ -18,10 +19,23 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import service from homeassistant.util.json import JsonValueType -from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER +from .const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, + ATTR_REQUESTED_BY, + ATTR_SEASONS, + ATTR_SORT_ORDER, + ATTR_STATUS, + DOMAIN, + LOGGER, +) from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" +SERVICE_SEARCH_MEDIA = "search_media" +SERVICE_REQUEST_MEDIA = "request_media" + SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): str, @@ -33,6 +47,29 @@ SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( } ) +SERVICE_SEARCH_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_QUERY): str, + } +) + +SERVICE_REQUEST_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEDIA_TYPE): vol.In(["movie", "tv"]), + vol.Required(ATTR_MEDIA_ID): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + vol.Optional(ATTR_SEASONS): vol.Any( + vol.Coerce(int), + [vol.Coerce(int)], + str, + ), + } +) + async def _get_media( client: OverseerrClient, media_type: str, identifier: int @@ -52,7 +89,7 @@ async def _get_media( async def _async_get_requests(call: ServiceCall) -> ServiceResponse: - """Get requests made to Overseerr.""" + """Get requests made to Seerr.""" entry: OverseerrConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) @@ -92,9 +129,79 @@ async def _async_get_requests(call: ServiceCall) -> ServiceResponse: return {"requests": cast(list[JsonValueType], result)} +async def _async_search_media(call: ServiceCall) -> ServiceResponse: + """Search for media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + query = call.data[ATTR_QUERY] + + LOGGER.debug("Searching for '%s'", query) + try: + search_results = await client.search(query) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return { + "results": cast( + list[JsonValueType], [asdict(result) for result in search_results] + ) + } + + +async def _async_request_media(call: ServiceCall) -> ServiceResponse: + """Request media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + media_type = call.data[ATTR_MEDIA_TYPE] + media_id = call.data[ATTR_MEDIA_ID] + seasons = parse_seasons_input(call.data.get(ATTR_SEASONS)) + + LOGGER.debug( + "Requesting %s with media ID %s (seasons: %s)", + media_type, + media_id, + seasons or "none", + ) + try: + # We can always pass in the seasons, they will be ignored if the media type isn't TV + request = await client.create_request(media_type, media_id, seasons) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return {"request": cast(JsonValueType, asdict(request))} + + +def parse_seasons_input(seasons_input: Any | None) -> Literal["all"] | list[int]: + """Parse all possible inputs to "all" or a list of integers.""" + seasons_str = str(seasons_input).strip() + if seasons_input is None or seasons_str in ("", "all"): + return "all" + + try: + parsed = ast.literal_eval(seasons_str) + if isinstance(parsed, int): + return [parsed] + return [int(season) for season in parsed] + except ValueError, SyntaxError, TypeError: + LOGGER.error("Unable to cast input to a list '%s'", seasons_input) + return "all" + + @callback def async_setup_services(hass: HomeAssistant) -> None: - """Set up the services for the Overseerr integration.""" + """Set up the services for the Seerr integration.""" hass.services.async_register( DOMAIN, @@ -103,3 +210,19 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_REQUESTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH_MEDIA, + _async_search_media, + schema=SERVICE_SEARCH_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_MEDIA, + _async_request_media, + schema=SERVICE_REQUEST_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml index c7593fc5aee1..3fcf49ba8f5d 100644 --- a/homeassistant/components/overseerr/services.yaml +++ b/homeassistant/components/overseerr/services.yaml @@ -28,3 +28,40 @@ get_requests: number: min: 0 mode: box + +search_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + query: + required: true + selector: + text: + +request_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + media_type: + required: true + selector: + select: + options: + - movie + - tv + translation_key: request_media_type + media_id: + required: true + selector: + number: + min: 1 + mode: box + seasons: + selector: + text: diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 9ddfc6929f6d..aa139f6cf919 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -118,6 +118,12 @@ } }, "selector": { + "request_media_type": { + "options": { + "movie": "Movie", + "tv": "TV" + } + }, "request_sort_order": { "options": { "added": "Added", @@ -157,6 +163,42 @@ } }, "name": "Get requests" + }, + "request_media": { + "description": "Creates a media request in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to create the request on.", + "name": "Seerr instance" + }, + "media_id": { + "description": "The TMDB ID or TVDB ID of the media to request.", + "name": "Media ID" + }, + "media_type": { + "description": "Type of media to request.", + "name": "Media type" + }, + "seasons": { + "description": "For TV requests: seasons to request. Optional list of integers (e.g., [1, 2, 4]). If omitted, all seasons will be requested.", + "name": "Seasons" + } + }, + "name": "Request media" + }, + "search_media": { + "description": "Searches for media in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to search.", + "name": "Seerr instance" + }, + "query": { + "description": "The search query.", + "name": "Query" + } + }, + "name": "Search media" } } } diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index b913092771fe..98330e34a0b2 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,5 +1,7 @@ """Constants for the Picnic integration.""" +from datetime import timedelta + DOMAIN = "picnic" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -18,6 +20,11 @@ SLOT_DATA = "slot_data" NEXT_DELIVERY_DATA = "next_delivery_data" LAST_ORDER_DATA = "last_order_data" +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) +DELIVERY_UPDATE_INTERVAL = timedelta(minutes=1) +DELIVERY_WINDOW_LEAD_TIME = timedelta(minutes=30) +DELIVERY_WINDOW_LAG_TIME = timedelta(hours=2) + SENSOR_CART_ITEMS_COUNT = "cart_items_count" SENSOR_CART_TOTAL_PRICE = "cart_total_price" SENSOR_SELECTED_SLOT_START = "selected_slot_start" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 43aca27b3bf2..8cc2b21a5be5 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -15,8 +15,19 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util -from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA +from .const import ( + ADDRESS, + CART_DATA, + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, + DELIVERY_WINDOW_LAG_TIME, + DELIVERY_WINDOW_LEAD_TIME, + LAST_ORDER_DATA, + NEXT_DELIVERY_DATA, + SLOT_DATA, +) type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator] @@ -42,12 +53,18 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): logger, config_entry=config_entry, name="Picnic coordinator", - update_interval=timedelta(minutes=30), + update_interval=DEFAULT_UPDATE_INTERVAL, ) @override async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" + # Recompute up front so failed refreshes also relax the cadence + if self.data: + self.update_interval = self._get_update_interval( + self.data.get(NEXT_DELIVERY_DATA) + ) + try: async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) @@ -63,9 +80,45 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): "Timeout while connecting to the Picnic API", retry_after=120 ) from error + self.update_interval = self._get_update_interval(data.get(NEXT_DELIVERY_DATA)) + # Return the fetched data return data + @staticmethod + def _get_update_interval(next_delivery: dict | None) -> timedelta: + """Poll faster around the delivery so the live ETA is picked up in time.""" + if not next_delivery: + return DEFAULT_UPDATE_INTERVAL + + eta = next_delivery.get("eta") + slot = next_delivery.get("slot") + + start = end = None + if eta: + start = dt_util.parse_datetime(str(eta.get("start"))) + end = dt_util.parse_datetime(str(eta.get("end"))) + if (start is None or end is None) and slot: + start = dt_util.parse_datetime(str(slot.get("window_start"))) + end = dt_util.parse_datetime(str(slot.get("window_end"))) + + if start is None or end is None: + return DEFAULT_UPDATE_INTERVAL + + now = dt_util.utcnow() + window_start = start - DELIVERY_WINDOW_LEAD_TIME + + if window_start <= now <= end + DELIVERY_WINDOW_LAG_TIME: + return DELIVERY_UPDATE_INTERVAL + + if now < window_start: + return max( + DELIVERY_UPDATE_INTERVAL, + min(DEFAULT_UPDATE_INTERVAL, window_start - now), + ) + + return DEFAULT_UPDATE_INTERVAL + def fetch_data(self): """Fetch data from the Picnic API. diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 2037ab45cac9..8aa037d7b386 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, @@ -198,13 +197,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class PortainerTimeout(HomeAssistantError): +class PortainerTimeout(Exception): """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 9787cd141e7c..395fe0b96413 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyportainer"], "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.38"] + "requirements": ["pyportainer==1.0.42"] } diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 53cb302e5d8d..1d7cb14848c3 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -6,7 +6,7 @@ from itertools import chain from typing import override from pyportainer import StackType -from pyportainer.models.docker import DockerSystemDF +from pyportainer.models.docker import DockerContainerState, DockerSystemDF from homeassistant.components.sensor import ( EntityCategory, @@ -84,7 +84,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( translation_key="container_state", value_fn=lambda data: data.container.state, device_class=SensorDeviceClass.ENUM, - options=["running", "exited", "paused", "restarting", "created", "dead"], + options=[state.value for state in DockerContainerState], ), PortainerContainerSensorEntityDescription( key="memory_limit", @@ -315,17 +315,11 @@ STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( PortainerStackSensorEntityDescription( key="stack_type", translation_key="stack_type", - value_fn=lambda data: ( - "swarm" - if data.stack.type == StackType.SWARM - else "compose" - if data.stack.type == StackType.COMPOSE - else "kubernetes" - if data.stack.type == StackType.KUBERNETES - else None - ), + value_fn=lambda data: { + stack.value: stack.name.lower() for stack in StackType + }.get(data.stack.type), device_class=SensorDeviceClass.ENUM, - options=["swarm", "compose", "kubernetes"], + options=[stack.name.lower() for stack in StackType], entity_category=EntityCategory.DIAGNOSTIC, ), PortainerStackSensorEntityDescription( diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index d32ebe40ce42..6f4991c5b571 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -105,6 +105,7 @@ "dead": "Dead", "exited": "Exited", "paused": "Paused", + "removing": "Removing", "restarting": "Restarting", "running": "Running" } diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py index da3d3f529e86..d8cd86263b30 100644 --- a/homeassistant/components/portainer/update.py +++ b/homeassistant/components/portainer/update.py @@ -112,9 +112,7 @@ async def async_setup_entry( class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity): """Representation of a Portainer container update.""" - _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS - ) + _attr_supported_features = UpdateEntityFeature.INSTALL entity_description: PortainerContainerUpdateEntityDescription @@ -130,7 +128,6 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity super().__init__(coordinator, entity_description, device_info, via_device) self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" - self._in_progress_old_version: str | None = None @override @property @@ -152,18 +149,11 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity """Return latest version.""" return self.entity_description.latest_version(self.container_data.image_status) - @override - @property - def in_progress(self) -> bool: - """Return if an update is in progress.""" - return self._in_progress_old_version == self.installed_version - @override async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install update.""" - self._in_progress_old_version = self.installed_version try: await self.entity_description.update_func( self.coordinator.portainer, @@ -183,5 +173,3 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity ) from ex else: await self.coordinator.async_request_refresh() - finally: - self._in_progress_old_version = None diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index d1ba2dede5d7..9ed5cfcea4de 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -39,8 +39,6 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, PERCENTAGE, @@ -770,14 +768,18 @@ class PrometheusMetrics: "Distance of the geo location event from home in meters", labels, ).set(value) - if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None: + if ( + latitude := state.attributes.get(EntityStateAttribute.LATITUDE) + ) is not None: self._metric( "geo_location_latitude_degrees", prometheus_client.Gauge, "Latitude of the geo location event in degrees", labels, ).set(latitude) - if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None: + if ( + longitude := state.attributes.get(EntityStateAttribute.LONGITUDE) + ) is not None: self._metric( "geo_location_longitude_degrees", prometheus_client.Gauge, diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index c304b4822f37..a5e4d179bcec 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -7,12 +7,11 @@ from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.components.person import ATTR_USER_ID from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant @@ -21,8 +20,8 @@ from .coordinator import ProximityConfigEntry TO_REDACT = { ATTR_GPS, ATTR_IP, - ATTR_LATITUDE, - ATTR_LONGITUDE, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, ATTR_MAC, ATTR_USER_ID, "context", diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 1dba1d6985ed..69f814a97c11 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -20,6 +20,7 @@ from .const import ( STORAGE_ENABLED, STORAGE_SHARED, VM_CONTAINER_RUNNING, + ProxmoxPermission, ) from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( @@ -28,6 +29,7 @@ from .entity import ( ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -51,6 +53,8 @@ class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class to hold Proxmox node binary sensor description.""" state_fn: Callable[[ProxmoxNodeData], bool | None] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -67,6 +71,8 @@ NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = ( state_fn=lambda data: data.node["status"] == NODE_ONLINE, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, + permission=ProxmoxPermission.VMAUDIT, # PVEVMUsers are allowed this node, through "/vms" + permission_target="vms", ), ProxmoxNodeBinarySensorEntityDescription( key="node_backup_status", @@ -132,10 +138,17 @@ async def async_setup_entry( def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: """Add new node binary sensors.""" + async_add_entities( ProxmoxNodeBinarySensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 5c5bdda0f114..b93e455dccab 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -17,7 +17,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -28,8 +28,6 @@ from .helpers import is_granted PARALLEL_UPDATES = 1 -NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" - @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): @@ -37,7 +35,6 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str], None] permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER - permission_raise: str = "no_permission_node_power" permission_target: str = "nodes" @@ -47,7 +44,6 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -57,7 +53,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -82,7 +77,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="start_all", translation_key="start_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -93,7 +87,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="stop_all", translation_key="stop_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -104,7 +97,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="suspend_all", translation_key="suspend_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -185,7 +177,6 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = ( ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -230,7 +221,6 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = ( ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -250,6 +240,12 @@ async def async_setup_entry( ProxmoxNodeButtonEntity(coordinator, entity_description, node) for node in nodes for entity_description in NODE_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( @@ -260,6 +256,12 @@ async def async_setup_entry( ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data) for (node_data, vm) in vms for entity_description in VM_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=vm["vmid"], + permission=entity_description.permission, + ) ) def _async_add_new_containers( @@ -272,6 +274,12 @@ async def async_setup_entry( ) for (node_data, container) in containers for entity_description in CONTAINER_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=container["vmid"], + permission=entity_description.permission, + ) ) coordinator.new_nodes_callbacks.append(_async_add_new_nodes) @@ -351,21 +359,10 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the node button action via executor.""" - node_id = self._node_data.node["node"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=node_id, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, - node_id, + self._node_data.node["node"], ) @@ -377,22 +374,11 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the VM button action via executor.""" - vmid = self.vm_data["vmid"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.vm_data["vmid"], ) @@ -404,21 +390,9 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the container button action via executor.""" - vmid = self.container_data["vmid"] - # Container power actions fall under vms - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.container_data["vmid"], ) diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index bfd944612a0d..8985a2a77ec9 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -41,4 +41,6 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSAUDIT = "Sys.Audit" SYSPOWER = "Sys.PowerMgmt" + VMAUDIT = "VM.Audit" diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index b701fa975a8b..09b04b21d7a1 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -354,9 +354,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) class ProxmoxSetupError(Exception): diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index 5701473fc8c4..d4140fc13d5e 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from .const import ProxmoxPermission from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( ProxmoxContainerEntity, @@ -25,6 +26,7 @@ from .entity import ( ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -34,6 +36,8 @@ class ProxmoxNodeSensorEntityDescription(SensorEntityDescription): """Class to hold Proxmox node sensor description.""" value_fn: Callable[[ProxmoxNodeData], StateType | datetime] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -147,6 +151,8 @@ NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = ( value_fn=lambda data: data.node["status"], device_class=SensorDeviceClass.ENUM, options=["online", "offline"], + permission=ProxmoxPermission.VMAUDIT, + permission_target="vms", ), ProxmoxNodeSensorEntityDescription( key="node_backup_last_backup", @@ -474,6 +480,12 @@ async def async_setup_entry( ProxmoxNodeSensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index fd35574b8fc2..904b88f894de 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -308,15 +308,6 @@ "no_nodes_found": { "message": "No active nodes were found on the Proxmox VE server." }, - "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." - }, - "no_permission_snapshot": { - "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." - }, - "no_permission_vm_lxc_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." - }, "no_vmlxc_found": { "message": "No LXC or VM were found on the Proxmox VE server." }, diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py index 9a557749494e..00f8c28d8a86 100644 --- a/homeassistant/components/ptdevices/__init__.py +++ b/homeassistant/components/ptdevices/__init__.py @@ -11,6 +11,7 @@ from .const import DEFAULT_URL from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator _PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] diff --git a/homeassistant/components/ptdevices/binary_sensor.py b/homeassistant/components/ptdevices/binary_sensor.py new file mode 100644 index 000000000000..b3858200c171 --- /dev/null +++ b/homeassistant/components/ptdevices/binary_sensor.py @@ -0,0 +1,121 @@ +"""PTDevices Binary Sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +PARALLEL_UPDATES = 0 + + +class PTDevicesBinarySensors(StrEnum): + """Store keys for PTDevices binary sensors.""" + + DEVICE_BATTERY_STATUS = "battery_status" + DEVICE_EXTERNAL_POWER = "external_power" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesBinarySensorEntityDescription(BinarySensorEntityDescription): + """Description for PTDevices binary sensor entities.""" + + is_on_fn: Callable[[dict[str, StateType]], bool | None] + + +BINARY_SENSOR_DESCRIPTIONS: tuple[PTDevicesBinarySensorEntityDescription, ...] = ( + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + translation_key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + None + if data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) + in (None, "unknown") + else data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) == "low" + ), + ), + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + translation_key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + bool(data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER)) + if data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER) is not None + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Setup PTDevices binary sensors based on config entry.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in BINARY_SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesBinarySensorEntity( + config_entry.runtime_data, sensor, device_id + ) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesBinarySensorEntity(PTDevicesEntity, BinarySensorEntity): + """Defines a PTDevices binary sensor.""" + + entity_description: PTDevicesBinarySensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py index 828034d089bd..6bb1b141610a 100644 --- a/homeassistant/components/ptdevices/coordinator.py +++ b/homeassistant/components/ptdevices/coordinator.py @@ -82,8 +82,6 @@ class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing stale device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return data["body"] diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json index 318c4fd1266d..9c5def4c87be 100644 --- a/homeassistant/components/ptdevices/strings.json +++ b/homeassistant/components/ptdevices/strings.json @@ -23,6 +23,11 @@ } }, "entity": { + "binary_sensor": { + "external_power": { + "name": "External power" + } + }, "sensor": { "battery_voltage": { "name": "Battery voltage" diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 0472eb35d892..ada6df9a8469 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -183,10 +183,7 @@ def _remove_stale_devices( "Removing device: %s because it no longer exists in your account", device.name, ) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 57b16d4059c3..ca86acc60ddb 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -98,7 +98,9 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dirty_water_box_status, is_dock_entity=True, - support_fn=lambda api: api.wash_towel_mode is not None, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.DIRTY_WATER_BOX_STATUS + ), ), RoborockBinarySensorDescription( key="clean_box_empty", @@ -107,7 +109,9 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.clear_water_box_status, is_dock_entity=True, - support_fn=lambda api: api.wash_towel_mode is not None, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.CLEAR_WATER_BOX_STATUS + ), ), RoborockBinarySensorDescription( key="clean_fluid_empty", @@ -120,9 +124,8 @@ BINARY_SENSOR_DESCRIPTIONS = [ else None ), is_dock_entity=True, - support_fn=lambda api: ( - api.wash_towel_mode is not None - and api.device_features.is_clean_fluid_delivery_supported + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.CLEAN_FLUID_STATUS ), ), RoborockBinarySensorDescription( diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 7cb45220f160..bd81c9e1f73e 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -19,6 +19,7 @@ from roborock.data import ( ZeoState, ) from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState +from roborock.data.v1.v1_containers import StatusField, StatusV2 from roborock.devices.traits.b01.q10.status import StatusTrait as Q10StatusTrait from roborock.devices.traits.v1 import PropertiesApi from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -259,9 +260,9 @@ SENSOR_DESCRIPTIONS = [ device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), is_dock_entity=True, - # Only available with more than just the basic dock. Dust collection - # mode is a proxy for any more complex dock type (e.g. Auto-empty). - support_fn=lambda api: api.dust_collection_mode is not None, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.DOCK_ERROR_STATUS + ), ), RoborockSensorDescription( key="mop_clean_remaining", diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 96617d4c2fa9..1fae7472c9b1 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -182,13 +182,16 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): what was available when the area mapping was last configured. """ super()._handle_coordinator_update() + # Avoid creating false-alarm issues if home map info is not yet loaded + if self._home_trait.home_map_info is None: + return last_seen = self.last_seen_segments if last_seen is None: # No area mapping has been configured yet; nothing to check. return current_ids = { f"{map_flag}_{room.segment_id}" - for map_flag, map_info in (self._home_trait.home_map_info or {}).items() + for map_flag, map_info in self._home_trait.home_map_info.items() for room in map_info.rooms } if current_ids != {seg.id for seg in last_seen}: diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index 69251608d09e..57564aeab24b 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -2,7 +2,7 @@ import logging -from pyrympro import CannotConnectError, RymPro, UnauthorizedError +from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant @@ -22,13 +22,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bo rympro.set_token(data[CONF_TOKEN]) try: await rympro.account_info() - except CannotConnectError as error: + except (CannotConnectError, OperationError) as error: raise ConfigEntryNotReady from error except UnauthorizedError: try: token = await rympro.login(data[CONF_EMAIL], data[CONF_PASSWORD], "ha") except UnauthorizedError as error: raise ConfigEntryAuthFailed from error + except CannotConnectError as error: + raise ConfigEntryNotReady from error hass.config_entries.async_update_entry( entry, data={**data, CONF_TOKEN: token}, diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 449c0722bde1..c07ca2fb8873 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -244,7 +244,7 @@ async def async_migrate_entry( # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(config_entry.entry_id) + dev_reg.async_clear_config_entry(config_entry.entry_id, config_entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(config_entry.entry_id) diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index f77df0155468..06b9bbb4b383 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -116,9 +116,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): if removed_locks := previous_locks - current_locks: LOGGER.debug("Removed locks: %s", ", ".join(removed_locks)) for lock_id in removed_locks: - device_registry.async_update_device( - device_id=previous_locks_by_lock_id[lock_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_locks_by_lock_id[lock_id].id ) if new_lock_ids := current_locks - previous_locks: diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 95177b7d2b46..1fb19d2663ca 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -236,20 +236,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> ) device_reg.async_update_device( device.id, - add_config_entry_id=entry.entry_id, - add_config_subentry_id=subentry_id, + new_config_entry_id=entry.entry_id, + new_config_subentry_id=subentry_id, new_identifiers=new_identifiers, ) - # Removing None from the list of subentries if existing - # as the device should only belong to the subentry - # and not the main config entry - device_reg.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - # Update the resource config new_config_entry_data = dict(entry.options) new_config_entry_data[CONF_AUTH] = {} diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 07187066dcde..dea32f63c792 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.14.1"] + "requirements": ["sense-energy==0.14.3"] } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b72e8aac270a..87a76f9daae1 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_MODEL, CONF_PASSWORD, CONF_USERNAME, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant @@ -294,6 +295,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) entry.data.get(CONF_PASSWORD), device_mac=entry.unique_id, port=get_http_port(entry.data), + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) ws_context = await get_ws_context(hass) diff --git a/homeassistant/components/shelly/ble_provisioning.py b/homeassistant/components/shelly/ble_provisioning.py index dc3eceecf87d..e37c63ac09fa 100644 --- a/homeassistant/components/shelly/ble_provisioning.py +++ b/homeassistant/components/shelly/ble_provisioning.py @@ -2,13 +2,12 @@ import asyncio from dataclasses import dataclass, field -import logging from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.util.hass_dict import HassKey -_LOGGER = logging.getLogger(__name__) +from .const import LOGGER @dataclass @@ -62,7 +61,7 @@ def async_register_zeroconf_discovery( state = registry.get(normalized_mac) if not state: - _LOGGER.debug( + LOGGER.debug( "No BLE provisioning state found for %s (host %s, port %s)", normalized_mac, host, @@ -70,7 +69,7 @@ def async_register_zeroconf_discovery( ) return - _LOGGER.debug( + LOGGER.debug( "Registering zeroconf discovery for %s at %s:%s (replacing previous)", normalized_mac, host, diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index e75a0a1ec4d2..5ebdddf23519 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -13,7 +13,12 @@ from aioshelly.ble.manufacturer_data import ( ) from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS +from aioshelly.const import ( + BLOCK_GENERATIONS, + DEFAULT_HTTP_PORT, + DEFAULT_HTTPS_PORT, + RPC_GENERATIONS, +) from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, @@ -51,6 +56,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow @@ -97,6 +103,7 @@ CONFIG_SCHEMA: Final = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_HTTP_PORT): vol.Coerce(int), + vol.Optional(CONF_VERIFY_SSL, default=False): bool, } ) @@ -144,6 +151,7 @@ async def validate_input( port: int, info: dict[str, Any], data: dict[str, Any], + verify_ssl: bool = False, ) -> dict[str, Any]: """Validate the user input allows us to connect. @@ -155,6 +163,7 @@ async def validate_input( password=data.get(CONF_PASSWORD), device_mac=info[CONF_MAC], port=port, + verify_ssl=verify_ssl, ) gen = get_info_gen(info) @@ -210,6 +219,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): host: str = "" port: int = DEFAULT_HTTP_PORT + verify_ssl: bool = False info: dict[str, Any] = {} device_info: dict[str, Any] = {} ble_device: BLEDevice | None = None @@ -223,6 +233,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): _discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth] _ble_rpc_device: RpcDevice | None = None + @staticmethod + def _get_ssl_entry_data(port: int, verify_ssl: bool) -> dict[str, bool]: + """Return SSL verification config entry data for HTTPS devices only.""" + if port != DEFAULT_HTTPS_PORT: + return {} + return {CONF_VERIFY_SSL: verify_ssl} + + @staticmethod + def _check_enhanced_security(info: dict[str, Any], port: int) -> int: + """Return HTTPS port if device reports enhanced_security is enabled.""" + if info.get("enhanced_security"): + return DEFAULT_HTTPS_PORT + + return port + @staticmethod def _get_name_from_mac_and_ble_model( mac: str, parsed_data: dict[str, int | str] @@ -407,7 +432,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return discovered async def _async_connect_and_get_info( - self, host: str, port: int + self, host: str, port: int, verify_ssl: bool = False ) -> ConfigFlowResult | None: """Connect to device, validate, and create entry or return None. @@ -419,18 +444,19 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): Sets self.info, self.host, and self.port on success. """ - self.info = await self._async_get_info(host, port) + self.info = await self._async_get_info(host, port, verify_ssl) await self.async_set_unique_id(self.info[CONF_MAC], raise_on_progress=False) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host - self.port = port + self.port = self._check_enhanced_security(self.info, port) + self.verify_ssl = verify_ssl if get_info_auth(self.info): return None # Continue to credentials step device_info = await validate_input( - self.hass, self.host, self.port, self.info, {} + self.hass, self.host, self.port, self.info, {}, self.verify_ssl ) if device_info[CONF_MODEL]: @@ -442,6 +468,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], + **self._get_ssl_entry_data(self.port, self.verify_ssl), }, ) return self.async_abort(reason="firmware_not_fully_provisioned") @@ -463,7 +490,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): # Zeroconf device - connect directly try: result = await self._async_connect_and_get_info( - device_data.host, device_data.port + device_data.host, device_data.port, verify_ssl=False ) except AbortFlow: raise # Let AbortFlow propagate (e.g., already_configured) @@ -551,7 +578,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: result = await self._async_connect_and_get_info( - user_input[CONF_HOST], user_input[CONF_PORT] + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_VERIFY_SSL], ) except AbortFlow: raise # Let AbortFlow propagate (e.g., already_configured) @@ -586,7 +615,12 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_USERNAME] = "admin" try: device_info = await validate_input( - self.hass, self.host, self.port, self.info, user_input + self.hass, + self.host, + self.port, + self.info, + user_input, + self.verify_ssl, ) except InvalidAuthError: errors["base"] = "invalid_auth" @@ -608,6 +642,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], + **self._get_ssl_entry_data(self.port, self.verify_ssl), }, ) return self.async_abort(reason="firmware_not_fully_provisioned") @@ -877,6 +912,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): None, device_mac=self.unique_id, port=port, + verify_ssl=self.verify_ssl, ) device: RpcDevice | None = None try: @@ -1000,19 +1036,28 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): self.port = state.port try: - self.info = await self._async_get_info(self.host, self.port) + self.info = await self._async_get_info( + self.host, self.port, self.verify_ssl + ) except DeviceConnectionError as err: LOGGER.debug("Failed to connect to device after WiFi provisioning: %s", err) # Device appeared on network but can't connect - allow retry return None + self.port = self._check_enhanced_security(self.info, self.port) + if get_info_auth(self.info): # Device requires authentication - show credentials step return await self.async_step_credentials() try: device_info = await validate_input( - self.hass, self.host, self.port, self.info, {} + self.hass, + self.host, + self.port, + self.info, + {}, + self.verify_ssl, ) except DeviceConnectionError as err: LOGGER.debug("Failed to validate device after WiFi provisioning: %s", err) @@ -1041,6 +1086,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], + **self._get_ssl_entry_data(self.port, self.verify_ssl), }, ) @@ -1123,6 +1169,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="ipv6_not_supported") host = discovery_info.host port = discovery_info.port or DEFAULT_HTTP_PORT + verify_ssl = False # First try to get the mac address from the name # so we can avoid making another connection to the # device if we already have it configured @@ -1132,7 +1179,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): try: # Devices behind range extender doesn't generate zeroconf packets # so port is always the default one - self.info = await self._async_get_info(host, port) + self.info = await self._async_get_info(host, port, verify_ssl) except DeviceConnectionError: return self.async_abort(reason="cannot_connect") @@ -1143,10 +1190,15 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self._async_handle_zeroconf_mac_discovery(mac, host, port) self.host = host + self.port = self._check_enhanced_security(self.info, port) + self.verify_ssl = verify_ssl self.context.update( { "title_placeholders": {"name": discovery_info.name.split(".")[0]}, - "configuration_url": f"http://{discovery_info.host}", + "configuration_url": ( + f"{'https' if self.port == DEFAULT_HTTPS_PORT else 'http'}://" + f"{discovery_info.host}" + ), } ) @@ -1155,7 +1207,12 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): try: self.device_info = await validate_input( - self.hass, self.host, self.port, self.info, {} + self.hass, + self.host, + self.port, + self.info, + {}, + self.verify_ssl, ) except DeviceConnectionError: return self.async_abort(reason="cannot_connect") @@ -1176,9 +1233,11 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): title=self.device_info["title"], data={ CONF_HOST: self.host, + CONF_PORT: self.port, CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], CONF_MODEL: self.device_info[CONF_MODEL], CONF_GEN: self.device_info[CONF_GEN], + **self._get_ssl_entry_data(self.port, self.verify_ssl), }, ) self._set_confirm_only() @@ -1206,24 +1265,33 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() host = reauth_entry.data[CONF_HOST] port = get_http_port(reauth_entry.data) + verify_ssl = reauth_entry.data.get(CONF_VERIFY_SSL, False) if user_input is not None: try: - info = await self._async_get_info(host, port) + info = await self._async_get_info(host, port, verify_ssl) except DeviceConnectionError, InvalidAuthError: return self.async_abort(reason="reauth_unsuccessful") if get_device_entry_gen(reauth_entry) != 1: user_input[CONF_USERNAME] = "admin" + + port = self._check_enhanced_security(info, port) + try: - await validate_input(self.hass, host, port, info, user_input) + await validate_input( + self.hass, host, port, info, user_input, verify_ssl + ) except DeviceConnectionError, InvalidAuthError: return self.async_abort(reason="reauth_unsuccessful") except MacAddressMismatchError: return self.async_abort(reason="mac_address_mismatch") + data_updates: dict[str, Any] = {CONF_PORT: port, **user_input} + data_updates.update(self._get_ssl_entry_data(port, verify_ssl)) + return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input + reauth_entry, data_updates=data_updates ) if get_device_entry_gen(reauth_entry) in BLOCK_GENERATIONS: @@ -1248,12 +1316,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): reconfigure_entry = self._get_reconfigure_entry() self.host = reconfigure_entry.data[CONF_HOST] self.port = reconfigure_entry.data.get(CONF_PORT, DEFAULT_HTTP_PORT) + self.verify_ssl = reconfigure_entry.data.get(CONF_VERIFY_SSL, False) if user_input is not None: host = user_input[CONF_HOST] port = user_input.get(CONF_PORT, DEFAULT_HTTP_PORT) + verify_ssl = user_input.get(CONF_VERIFY_SSL, False) try: - info = await self._async_get_info(host, port) + info = await self._async_get_info(host, port, verify_ssl) except DeviceConnectionError: errors["base"] = "cannot_connect" except CustomPortNotSupported: @@ -1262,9 +1332,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(info[CONF_MAC]) self._abort_if_unique_id_mismatch(reason="another_device") + port = self._check_enhanced_security(info, port) + + data_updates: dict[str, Any] = { + CONF_HOST: host, + CONF_PORT: port, + } + if ( + port == DEFAULT_HTTPS_PORT + or CONF_VERIFY_SSL in reconfigure_entry.data + ): + data_updates[CONF_VERIFY_SSL] = verify_ssl + return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={CONF_HOST: host, CONF_PORT: port}, + data_updates=data_updates, ) return self.async_show_form( @@ -1273,15 +1355,20 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_HOST, default=self.host): str, vol.Required(CONF_PORT, default=self.port): vol.Coerce(int), + vol.Optional(CONF_VERIFY_SSL, default=self.verify_ssl): bool, } ), description_placeholders={"device_name": reconfigure_entry.title}, errors=errors, ) - async def _async_get_info(self, host: str, port: int) -> dict[str, Any]: + async def _async_get_info( + self, host: str, port: int, verify_ssl: bool + ) -> dict[str, Any]: """Get info from shelly device.""" - return await get_info(async_get_clientsession(self.hass), host, port=port) + return await get_info( + async_get_clientsession(self.hass), host, port=port, verify_ssl=verify_ssl + ) @callback @override diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 53b665b55932..fb9caa3685b8 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -8,7 +8,7 @@ from typing import Any, cast, override from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType -from aioshelly.const import MODEL_VALVE +from aioshelly.const import DEFAULT_HTTPS_PORT, MODEL_VALVE from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -150,7 +150,9 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def configuration_url(self) -> str: """Return the configuration URL for the device.""" - return f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}" + port = get_http_port(self.config_entry.data) + scheme = "https" if port == DEFAULT_HTTPS_PORT else "http" + return f"{scheme}://{get_host(self.config_entry.data[CONF_HOST])}:{port}" @cached_property def model(self) -> str: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 02f5979e2158..93d326bc33fe 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.26.2"], + "requirements": ["aioshelly==13.27.0"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index bad550bfd3d7..1a25e2112c3a 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -72,11 +72,13 @@ "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" + "port": "[%key:common::config_flow::data::port%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "host": "[%key:component::shelly::config::step::user_manual::data_description::host%]", - "port": "[%key:component::shelly::config::step::user_manual::data_description::port%]" + "port": "[%key:component::shelly::config::step::user_manual::data_description::port%]", + "verify_ssl": "[%key:component::shelly::config::step::user_manual::data_description::verify_ssl%]" }, "description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it." }, @@ -92,11 +94,13 @@ "user_manual": { "data": { "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" + "port": "[%key:common::config_flow::data::port%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "host": "The hostname or IP address of the Shelly device to connect to.", - "port": "The TCP port of the Shelly device to connect to (Gen2+)." + "port": "The TCP port of the Shelly device to connect to (Gen2+).", + "verify_ssl": "Verify SSL/TLS certificate when connecting on HTTPS (port 443, Gen2+)." }, "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it." }, diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 304a90d19ae7..ca570a80b526 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -import logging from typing import Any, Final, cast, override from aioshelly.const import RPC_GENERATIONS @@ -25,6 +24,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( CONF_SLEEP_PERIOD, DOMAIN, + LOGGER, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, @@ -42,8 +42,6 @@ from .entity import ( ) from .utils import get_device_entry_gen, get_release_url -LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 9eccf34badc6..a6aebddbc023 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -916,7 +916,7 @@ def remove_stale_blu_trv_devices( continue LOGGER.debug("Removing stale BLU TRV device %s", device.name) - dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) + dev_reg.async_remove_device(device.id) @callback @@ -938,9 +938,7 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: if any(identifier[0] == DOMAIN for identifier in device.identifiers): LOGGER.debug("Removing empty sub-device %s", device.name) - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) def format_ble_addr(ble_addr: str) -> str: diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 77abd69ac833..694f4e98a6fb 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -27,6 +27,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS @@ -34,6 +39,39 @@ from .const import CONF_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +STEP_DISCOVERY_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + async def validate_input( hass: HomeAssistant, user_input: dict[str, Any], @@ -130,18 +168,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self._data[CONF_HOST]): cv.string, - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input, ), errors=errors, ) @@ -172,20 +201,14 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SSL: user_input[CONF_SSL], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], CONF_GROUP: user_input[CONF_GROUP], + CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_GROUP): vol.In(GROUPS), - } - ), + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input or dict(reconf_entry.data), ), errors=errors, @@ -221,7 +244,12 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, @@ -290,17 +318,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", - data_schema=vol.Schema( - { - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_DISCOVERY_CONFIRM_DATA_SCHEMA, + suggested_values=user_input, ), description_placeholders={CONF_HOST: self._data[CONF_HOST]}, errors=errors, diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 82d8e751498d..1eb9559a2b8f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -314,9 +314,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for device_identifier in device_status ): continue - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) return True diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index 52a7641aa5f2..28e43b3c9388 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -10,6 +10,7 @@ from typing import Any, override import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ( SOURCE_USER, @@ -59,11 +60,28 @@ from .const import ( DEFAULT_TIMEOUT, DOMAIN, ENCRYPTION_OPTIONS, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) _LOGGER = logging.getLogger(__name__) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, + max=1800, + step=1, + unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ) + } +) STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -115,23 +133,6 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( } ) -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( - NumberSelector( - NumberSelectorConfig( - min=1, - max=1800, - step=1, - unit_of_measurement=UnitOfTime.SECONDS, - mode=NumberSelectorMode.BOX, - ) - ), - vol.Coerce(int), - ) - } -) - class MailConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMTP.""" @@ -166,16 +167,29 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + entry_data = user_input.copy() + options = entry_data.pop(SECTION_OPTIONS) + errors = await self.hass.async_add_executor_job( + validate_input, entry_data, options + ) if not errors: return self.async_create_entry( - title=user_input.get(CONF_SENDER_NAME, user_input[CONF_SENDER]), - data=user_input, + title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]), + data=entry_data, + options=options, ) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + data_schema=STEP_USER_DATA_SCHEMA.extend( + { + vol.Required(SECTION_OPTIONS): data_entry_flow.section( + OPTIONS_SCHEMA, + {"collapsed": True}, + ), + } + ), + suggested_values=user_input, ), errors=errors, ) @@ -209,7 +223,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + errors = await self.hass.async_add_executor_job( + validate_input, user_input, dict(entry.options) + ) if not errors: return self.async_update_and_abort( entry, @@ -240,7 +256,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: errors = await self.hass.async_add_executor_job( - validate_input, {**entry.data, **user_input} + validate_input, {**entry.data, **user_input}, dict(entry.options) ) if not errors: return self.async_update_and_abort( @@ -263,7 +279,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) - errors = await self.hass.async_add_executor_job(validate_input, import_info) + errors = await self.hass.async_add_executor_job( + validate_input, import_info, options + ) if not errors: title = ( import_info.get(CONF_NAME) @@ -288,7 +306,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=errors["base"]) -def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +def validate_input( + user_input: dict[str, Any], options: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" errors: dict[str, str] = {} ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None @@ -298,12 +318,14 @@ def validate_input(user_input: dict[str, Any]) -> dict[str, str]: mail = SMTP_SSL( user_input[CONF_SERVER], user_input[CONF_PORT], - timeout=DEFAULT_TIMEOUT, + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), context=ssl_context, ) else: mail = SMTP( - user_input[CONF_SERVER], user_input[CONF_PORT], timeout=DEFAULT_TIMEOUT + user_input[CONF_SERVER], + user_input[CONF_PORT], + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) mail.ehlo_or_helo_if_needed() if user_input[CONF_ENCRYPTION] == "starttls": diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index dc9fccd3d5ab..78fb8d99cf27 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -11,6 +11,7 @@ ATTR_SENDER_NAME: Final = "sender_name" CONF_ENCRYPTION: Final = "encryption" CONF_SERVER: Final = "server" CONF_SENDER_NAME: Final = "sender_name" +SECTION_OPTIONS: Final = "options" DEFAULT_HOST: Final = "localhost" DEFAULT_PORT: Final = 587 diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 9908b7c9f521..c48c4798e429 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -67,6 +67,17 @@ "server": "Hostname or IP address of the SMTP server. For example, `smtp.example.com`.", "username": "Username used to authenticate with the SMTP server.", "verify_ssl": "Enable certificate verification for secure SSL/TLS connections." + }, + "sections": { + "options": { + "data": { + "timeout": "[%key:component::smtp::options::step::init::data::timeout%]" + }, + "data_description": { + "timeout": "[%key:component::smtp::options::step::init::data_description::timeout%]" + }, + "name": "Additional options" + } } } } diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 1678eac5e599..949b7acc440c 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -264,8 +264,11 @@ class SonosDiscoveryManager: visible_zones = soco.visible_zones self._known_invisible = soco.all_zones - visible_zones for zone in visible_zones: - if zone.uid not in self.data.discovered: - zones_to_add.add(zone) + if zone.uid in self.data.discovered or self.is_device_disabled( + zone.uid + ): + continue + zones_to_add.add(zone) if not zones_to_add: return @@ -540,6 +543,16 @@ class SonosDiscoveryManager: self.hass, DISCOVERY_INTERVAL.total_seconds(), self.async_poll_manual_hosts ) + def is_device_disabled(self, uid: str) -> bool: + """Check if the Sonos device is disabled in the device registry.""" + if not ( + device := dr.async_get(self.hass).async_get_device( + identifiers={(DOMAIN, uid)} + ) + ): + return False + return device.disabled + async def _async_handle_discovery_message( self, uid: str, @@ -548,6 +561,10 @@ class SonosDiscoveryManager: boot_seqnum: int | None = None, ) -> None: """Handle discovered player creation and activity.""" + if self.is_device_disabled(uid): + _LOGGER.debug("Skipping %s for disabled Sonos device: %s", source, uid) + return + async with self.discovery_lock: if not self.data.discovered: # Initial discovery, attempt to add all visible zones diff --git a/homeassistant/components/sonos/button.py b/homeassistant/components/sonos/button.py new file mode 100644 index 000000000000..c286a9363c9f --- /dev/null +++ b/homeassistant/components/sonos/button.py @@ -0,0 +1,50 @@ +"""Button entities for Sonos.""" + +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SONOS_CREATE_BUTTON +from .entity import SonosEntity +from .helpers import SonosConfigEntry +from .speaker import SonosSpeaker + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sonos button entities from a config entry.""" + + @callback + def async_create_entities(speaker: SonosSpeaker) -> None: + """Handle device discovery and create button entities.""" + async_add_entities([SonosCancelAnnouncementButton(speaker, config_entry)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BUTTON, async_create_entities) + ) + + +class SonosCancelAnnouncementButton(SonosEntity, ButtonEntity): + """Button to cancel the current Sonos announcement.""" + + _attr_translation_key = "cancel_announcement" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the cancel announcement button.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-cancel_announcement" + + @override + async def _async_fallback_poll(self) -> None: + """No-op: button state does not need polling.""" + + @override + async def async_press(self) -> None: + """Cancel the current announcement audio clip.""" + await self.speaker.async_cancel_announcement() diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 3142e72e6854..07d7d11ea461 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -11,6 +11,7 @@ DOMAIN = "sonos" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, @@ -159,6 +160,7 @@ PLAYABLE_MEDIA_TYPES = [ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" +SONOS_CREATE_BUTTON = "sonos_create_button" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 2b4df23b4ccc..db7229c2e15c 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send -from .const import SONOS_SPEAKER_ACTIVITY +from .const import DOMAIN, SONOS_SPEAKER_ACTIVITY from .exception import SonosUpdateError if TYPE_CHECKING: @@ -30,6 +30,8 @@ if TYPE_CHECKING: UID_PREFIX = "RINCON_" UID_POSTFIX = "01400" +UPNP_ERROR_COMMAND_FAILED = "800" + _LOGGER = logging.getLogger(__name__) type _SonosEntitiesType = ( @@ -76,8 +78,24 @@ def soco_error[_T: _SonosEntitiesType, **_P, _R]( if (target := _find_target_identifier(self, args_soco)) is None: raise RuntimeError("Unexpected use of soco_error") from err - message = f"Error calling {function} on {target}: {err}" - raise SonosUpdateError(message) from err + translation_key = "call_failed" + placeholders = { + "target": target, + "error": str(err), + } + + if error_code is not None: + translation_key = "upnp_call_failed" + placeholders["error_code"] = str(error_code) + + if str(error_code) == UPNP_ERROR_COMMAND_FAILED: + translation_key = "upnp_call_failed_music_service_unavailable" + + raise SonosUpdateError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) from err dispatch_soco = args_soco or self.soco # type: ignore[union-attr] dispatcher_send( diff --git a/homeassistant/components/sonos/icons.json b/homeassistant/components/sonos/icons.json index e28e4c305a99..2c16c854be9a 100644 --- a/homeassistant/components/sonos/icons.json +++ b/homeassistant/components/sonos/icons.json @@ -5,6 +5,11 @@ "default": "mdi:microphone" } }, + "button": { + "cancel_announcement": { + "default": "mdi:cancel" + } + }, "sensor": { "audio_input_format": { "default": "mdi:import" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 001f0c9e220e..90d59e0c7db4 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -2,7 +2,7 @@ "domain": "sonos", "name": "Sonos", "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], - "codeowners": ["@jjlawren", "@peterager"], + "codeowners": ["@peterager", "@jjlawren"], "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index d1f5fb1b2cc4..94de448dc6ae 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -17,6 +17,7 @@ from soco.core import ( from soco.data_structures import DidlFavorite, DidlMusicTrack from soco.exceptions import SoCoException from soco.ms_data_structures import MusicServiceItem +from sonos_websocket import CLIP_ID_KEY from sonos_websocket.exception import SonosWebsocketError from homeassistant.components import media_source, spotify @@ -528,8 +529,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: + self.speaker.last_announce_id = None assert self.speaker.websocket - response, _ = await self.speaker.websocket.play_clip( + response, data = await self.speaker.websocket.play_clip( async_process_play_media_url(self.hass, media_id), volume=volume, ) @@ -538,6 +540,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): f"Error when calling Sonos websocket: {exc}" ) from exc if response.get("success"): + if data: + self.speaker.last_announce_id = data.get(CLIP_ID_KEY) return if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS: # If the speaker does not support announce do not raise and diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f55204491156..24cd43bbcb3e 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -17,10 +17,11 @@ from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket +from sonos_websocket.exception import SonosWebsocketError from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -44,6 +45,7 @@ from .const import ( SONOS_CREATE_ALARM, SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, + SONOS_CREATE_BUTTON, SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, @@ -186,6 +188,9 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() + # Announcement tracking + self.last_announce_id: str | None = None + async def async_setup( self, entry: SonosConfigEntry, @@ -261,6 +266,7 @@ class SonosSpeaker: dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) + dispatches.append((SONOS_CREATE_BUTTON, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -1294,6 +1300,35 @@ class SonosSpeaker: any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() + async def async_cancel_announcement(self) -> None: + """Cancel the current announcement audio clip.""" + if self.last_announce_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_no_id", + ) + if not self.websocket: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": "websocket not available"}, + ) + try: + response, _ = await self.websocket.cancel_clip(self.last_announce_id) + except SonosWebsocketError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": str(exc)}, + ) from exc + if not response.get("success"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_error", + translation_placeholders={"response": str(response)}, + ) + self.last_announce_id = None + # # Media and playback state handlers # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index f2e01da70fa3..82c99a7e1d23 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -18,6 +18,11 @@ "name": "Microphone" } }, + "button": { + "cancel_announcement": { + "name": "Cancel announcement" + } + }, "number": { "audio_delay": { "name": "Audio delay" @@ -109,6 +114,18 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "announcement_connection_error": { + "message": "Failed to reach Sonos speaker for announcement: {error}" + }, + "call_failed": { + "message": "Error on {target}: {error}" + }, + "cancel_announcement_error": { + "message": "Cancelling announcement failed: {response}" + }, + "cancel_announcement_no_id": { + "message": "No active announcement to cancel" + }, "entity_not_found": { "message": "Entity {entity_id} not found." }, @@ -141,6 +158,12 @@ }, "toggle_failed": { "message": "Could not toggle {entity_id}." + }, + "upnp_call_failed": { + "message": "Error on {target} (UPnP error code {error_code}): {error}" + }, + "upnp_call_failed_music_service_unavailable": { + "message": "Error on {target} (UPnP error code {error_code}): {error}. This may indicate the selected music service is not available on the speaker." } }, "issues": { diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 80fa4d9f8af2..f7cda744db7f 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -1,6 +1,6 @@ """Config flow to configure StarLine component.""" -from typing import override +from typing import TYPE_CHECKING, override from starline import StarlineAuth import voluptuous as vol @@ -192,13 +192,18 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Authenticate application.""" try: - self._app_code = await self.hass.async_add_executor_job( - self._auth.get_app_code, self._app_id, self._app_secret - ) - # pylint: disable-next=home-assistant-sequential-executor-jobs - self._app_token = await self.hass.async_add_executor_job( - self._auth.get_app_token, self._app_id, self._app_secret, self._app_code - ) + + def _get_app_token() -> str: + if TYPE_CHECKING: + assert self._app_id is not None + assert self._app_secret is not None + + app_code = self._auth.get_app_code(self._app_id, self._app_secret) + return self._auth.get_app_token( + self._app_id, self._app_secret, app_code + ) + + self._app_token = await self.hass.async_add_executor_job(_get_app_token) return self._async_form_auth_user(error) except Exception as err: # noqa: BLE001 _LOGGER.error("Error auth StarLine: %s", err) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 49dcb19ceb56..4de69276a9a2 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -35,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index ea2f37f11f52..bc929103df13 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -45,6 +45,7 @@ class PlayerData: loccityid: int | None = None gameextrainfo: str | None = None gameid: str | None = None + lobbysteamid: str | None = None level: int | None = None diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 0d8c4ba8f4bd..45c1cdac4de0 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -60,6 +60,8 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( options=list(STEAM_STATUSES.values()), entity_picture_fn=lambda x, _: x.avatarfull, name=None, + # Attributes game, game_id, game_image_header, game_image_main, game_icon, + # last_online, and level are deprecated and can be removed in 2027.2 extra_state_attributes_fn=lambda x, icons: { "real_name": x.realname, "created": ( diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py index 24ea65eb465d..2e77211698ce 100644 --- a/homeassistant/components/subaru/button.py +++ b/homeassistant/components/subaru/button.py @@ -10,15 +10,14 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( SERVICE_REMOTE_START, SERVICE_REMOTE_STOP, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_START, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruEntity from .remote_service import async_call_remote_service @@ -59,10 +58,9 @@ async def async_setup_entry( ) -class SubaruButton(ButtonEntity): +class SubaruButton(SubaruEntity, ButtonEntity): """Class for a Subaru button.""" - _attr_has_entity_name = True entity_description: SubaruButtonEntityDescription def __init__( @@ -73,13 +71,10 @@ class SubaruButton(ButtonEntity): description: SubaruButtonEntityDescription, ) -> None: """Initialize the button for the vehicle.""" + super().__init__(vehicle_info, description.key) self.controller = controller self.coordinator = coordinator - self.vehicle_info = vehicle_info self.entity_description = description - vin = vehicle_info[VEHICLE_VIN] - self._attr_unique_id = f"{vin}_{description.key}" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_press(self) -> None: diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 9ea7929b5dcf..f31ac633893b 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -7,11 +7,10 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import get_device_info -from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN +from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity async def async_setup_entry( @@ -29,23 +28,17 @@ async def async_setup_entry( ) -class SubaruDeviceTracker( - CoordinatorEntity[SubaruDataUpdateCoordinator], TrackerEntity -): +class SubaruDeviceTracker(SubaruCoordinatorEntity, TrackerEntity): """Class for Subaru device tracker.""" _attr_translation_key = "location" - _attr_has_entity_name = True _attr_name = None def __init__( self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator ) -> None: """Initialize the device tracker.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_location" + super().__init__(vehicle_info, coordinator, "location") @property @override @@ -72,8 +65,8 @@ class SubaruDeviceTracker( @property @override def available(self) -> bool: - """Return if entity is available.""" - if vehicle_data := self.coordinator.data.get(self.vin): - if status := vehicle_data.get(VEHICLE_STATUS): - return status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP} - return False + """Return if available; not gated on last_update_success, only on the relevant status keys being present.""" + if not (vehicle_data := (self.coordinator.data or {}).get(self.vin)): + return False + status = vehicle_data.get(VEHICLE_STATUS) or {} + return bool(status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP}) diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py new file mode 100644 index 000000000000..a9e4ff154615 --- /dev/null +++ b/homeassistant/components/subaru/entity.py @@ -0,0 +1,45 @@ +"""Base entities for the Subaru integration.""" + +from typing import Any, override + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import get_device_info +from .const import VEHICLE_VIN +from .coordinator import SubaruDataUpdateCoordinator + + +class SubaruEntity(Entity): + """Base class for Subaru entities: device_info, unique_id, has_entity_name.""" + + _attr_has_entity_name = True + + def __init__(self, vehicle_info: dict[str, Any], unique_id_suffix: str) -> None: + """Initialize the entity from the vehicle_info dict.""" + self.vehicle_info = vehicle_info + self.vin: str = vehicle_info[VEHICLE_VIN] + self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_{unique_id_suffix}" + + +class SubaruCoordinatorEntity( + CoordinatorEntity[SubaruDataUpdateCoordinator], SubaruEntity +): + """Base class for coordinator-backed Subaru entities.""" + + def __init__( + self, + vehicle_info: dict[str, Any], + coordinator: SubaruDataUpdateCoordinator, + unique_id_suffix: str, + ) -> None: + """Initialize the coordinator-backed entity.""" + super().__init__(coordinator) + SubaruEntity.__init__(self, vehicle_info, unique_id_suffix) + + @property + @override + def available(self) -> bool: + """Return if available; also gates on data for this vehicle being present.""" + return super().available and self.vin in self.coordinator.data diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 62547ee51e5b..362e3ebe4c3d 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( ATTR_DOOR, SERVICE_UNLOCK_SPECIFIC_DOOR, @@ -19,9 +18,9 @@ from .const import ( UNLOCK_VALID_DOORS, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_NAME, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry +from .entity import SubaruEntity from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ async def async_setup_entry( ) -class SubaruLock(LockEntity): +class SubaruLock(SubaruEntity, LockEntity): """Representation of a Subaru door lock. Note that the Subaru API currently does not support @@ -58,17 +57,13 @@ class SubaruLock(LockEntity): always unknown. """ - _attr_has_entity_name = True _attr_translation_key = "door_locks" def __init__(self, vehicle_info, controller): """Initialize the locks for the vehicle.""" + super().__init__(vehicle_info, "door_locks") self.controller = controller - self.vehicle_info = vehicle_info - vin = vehicle_info[VEHICLE_VIN] self.car_name = vehicle_info[VEHICLE_NAME] - self._attr_unique_id = f"{vin}_door_locks" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 1a49fbba510d..4fff8efb10ba 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -27,11 +27,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import METRIC_SYSTEM -from . import get_device_info from .const import ( API_GEN_2, API_GEN_3, @@ -42,9 +40,9 @@ from .const import ( VEHICLE_HAS_EV, VEHICLE_HEALTH, VEHICLE_STATUS, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -260,10 +258,9 @@ def create_vehicle_sensors( ] -class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity): +class SubaruSensor(SubaruCoordinatorEntity, SensorEntity): """Class for Subaru sensors.""" - _attr_has_entity_name = True entity_description: SubaruSensorEntityDescription def __init__( @@ -273,11 +270,8 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) description: SubaruSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] + super().__init__(vehicle_info, coordinator, description.key) self.entity_description = description - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_{description.key}" @property @override @@ -312,15 +306,6 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS return self.entity_description.native_unit_of_measurement - @property - @override - def available(self) -> bool: - """Return if entity is available.""" - last_update_success = super().available - if last_update_success and self.vin not in self.coordinator.data: - return False - return last_update_success - async def _async_migrate_entries( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 262457c4620e..d7e67ba34fbf 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant @@ -41,8 +42,8 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( SuezWaterSensorEntityDescription( key="water_price", translation_key="water_price", - native_unit_of_measurement=CURRENCY_EURO, - device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda suez_data: suez_data.price, ), ) diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 6a13d3c5d1ef..c4012e10f588 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -59,10 +59,7 @@ def _remove_missing_devices( continue if domain_device_ids.isdisjoint(known_device_ids): - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index fe1e92ab6f26..c17a591e64c1 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -128,9 +128,7 @@ async def async_migrate_entry( device_registry, config_entry_id=config_entry.entry_id ) for dev in device_entries: - device_registry.async_update_device( - dev.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(dev.id) entity_id = entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, "None_departure" diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index 8b259e82d90d..6beb51f2b7fe 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/swisscom", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["python-swisscom-internet-box==0.1.1"] + "requirements": ["python-swisscom-internet-box==0.2.0"] } diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index ef0a5cc5e3a0..e44aa0da3b1d 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -60,7 +60,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_get_parent_device_id(hass, entity_id), diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index ddde26ae083f..6d22cb777f22 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( @@ -223,9 +222,9 @@ class OptionsFlowHandler(OptionsFlow): return self.async_show_form(step_id="init", data_schema=data_schema) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class TadoRateLimitExceeded(HomeAssistantError): +class TadoRateLimitExceeded(Exception): """Error to indicate Tado API rate limit exceeded.""" diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index c8dd1b396dad..1e47c5344a60 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -108,9 +108,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf for station_id in self._selected_stations ): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) if len(self.stations) > 10: _LOGGER.warning( diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 18c979b2f373..844aed33075a 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -3,6 +3,7 @@ import logging from typing import Protocol, cast +import telegram from telegram import Bot from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError @@ -33,6 +34,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, issue_registry as ir, ) @@ -104,6 +106,7 @@ from .const import ( CHAT_ACTION_UPLOAD_VIDEO_NOTE, CHAT_ACTION_UPLOAD_VOICE, CONF_API_ENDPOINT, + CONF_CHAT_ID, CONF_CONFIG_ENTRY_ID, DEFAULT_API_ENDPOINT, DOMAIN, @@ -705,6 +708,46 @@ async def async_migrate_entry( updated, ) + # version 1.2 -> 1.3: give each chat its own device, linked to the shared bot device, + # and make sure the bot device is tied to (entry, None). + if version == 1 and config_entry.minor_version < 3: + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + if devices: + bot_device = devices[0] + bot_id = next( + identifier + for domain, identifier in bot_device.identifiers + if domain == DOMAIN + ) + notify_entities = { + entity.config_subentry_id: entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + # The event entity (no subentry) stays on the shared bot device + if entity.config_subentry_id is not None + } + for subentry_id, subentry in config_entry.subentries.items(): + per_chat_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_id, + identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")}, + via_device_id=bot_device.id, + ) + if entity := notify_entities.get(subentry_id): + entity_registry.async_update_entity( + entity.entity_id, device_id=per_chat_device.id + ) + # Hand the bot device back to (entry, None), keeping the event entity + device_registry.async_update_device( + bot_device.id, new_config_subentry_id=None + ) + hass.config_entries.async_update_entry(config_entry, minor_version=3) + return True @@ -906,6 +949,18 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]: return chat_ids +def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo: + """Return device info for the shared bot device.""" + return dr.DeviceInfo( + name=config_entry.title, + entry_type=dr.DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{bot_id}")}, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool: """Create the Telegram bot from config entry.""" bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data) @@ -933,6 +988,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) ) entry.runtime_data = notify_service + # Create the bot device before the platforms are set up, so the per-chat devices can + # resolve it as their via_device no matter which platform is set up first + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **bot_device_info(entry, bot.id) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 6d5422b8368b..0aa84bc996c9 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -192,7 +192,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Telegram.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py index 95adc934781a..1b71426a89fe 100644 --- a/homeassistant/components/telegram_bot/entity.py +++ b/homeassistant/components/telegram_bot/entity.py @@ -1,13 +1,8 @@ """Base entity for Telegram bot integration.""" -import telegram - -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from . import TelegramBotConfigEntry -from .const import DOMAIN +from . import TelegramBotConfigEntry, bot_device_info class TelegramBotEntity(Entity): @@ -28,11 +23,4 @@ class TelegramBotEntity(Entity): self.service = config_entry.runtime_data self._attr_unique_id = f"{self.bot_id}_{entity_description.key}" - self._attr_device_info = DeviceInfo( - name=config_entry.title, - entry_type=DeviceEntryType.SERVICE, - manufacturer="Telegram", - model=config_entry.data[CONF_PLATFORM].capitalize(), - sw_version=telegram.__version__, - identifiers={(DOMAIN, f"{self.bot_id}")}, - ) + self._attr_device_info = bot_device_info(config_entry, self.bot_id) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py index c49d106a84d2..e95cf2de681e 100644 --- a/homeassistant/components/telegram_bot/notify.py +++ b/homeassistant/components/telegram_bot/notify.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelegramBotConfigEntry -from .const import ATTR_TITLE, CONF_CHAT_ID +from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN from .entity import TelegramBotEntity @@ -33,6 +33,7 @@ async def async_setup_entry( class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): """Representation of a telegram bot notification entity.""" + _attr_name = None _attr_supported_features = NotifyEntityFeature.TITLE def __init__( @@ -45,7 +46,12 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID]) ) self.chat_id = subentry.data[CONF_CHAT_ID] - self._attr_name = subentry.title + # Each chat gets its own device (keyed per chat) linked to the shared bot device. + device_info = self._attr_device_info + assert device_info is not None + device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")} + device_info["name"] = subentry.title + device_info["via_device"] = (DOMAIN, f"{self.bot_id}") @override async def async_send_message(self, message: str, title: str | None = None) -> None: diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 1ba5fa21e824..b825552e8170 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -29,7 +29,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS +from .const import ( + CONF_ADDITIONAL_OPTIONS, + CONF_MAX, + CONF_MIN, + CONF_STEP, + DOMAIN, + PLATFORMS, +) from .coordinator import TriggerUpdateCoordinator from .helpers import async_get_blueprints @@ -141,6 +148,14 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, version=1, minor_version=2 ) + options = {**config_entry.options} + # The "advanced_options" section was renamed to "additional_options" + if (additional := options.pop("advanced_options", None)) is not None: + options[CONF_ADDITIONAL_OPTIONS] = additional + hass.config_entries.async_update_entry( + config_entry, options=options, version=2, minor_version=1 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 0d28bc3c6aa9..934cd6a3f6bf 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -60,7 +60,7 @@ from .alarm_control_panel import ( ) from .binary_sensor import async_create_preview_binary_sensor from .const import ( - CONF_ADVANCED_OPTIONS, + CONF_ADDITIONAL_OPTIONS, CONF_AVAILABILITY, CONF_PRESS, CONF_TURN_OFF, @@ -157,7 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = { def generate_schema(domain: str, flow_type: str) -> vol.Schema: """Generate schema.""" schema: dict[vol.Marker, Any] = {} - advanced_options: dict[vol.Marker, Any] = {} + additional_options: dict[vol.Marker, Any] = {} if flow_type == "config": schema = {vol.Required(CONF_NAME): selector.TextSelector()} @@ -240,7 +240,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_LATITUDE): selector.TemplateSelector(), vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(), } - advanced_options |= { + additional_options |= { vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(), } @@ -445,11 +445,11 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: schema |= { vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_ADVANCED_OPTIONS): section( + vol.Optional(CONF_ADDITIONAL_OPTIONS): section( vol.Schema( { vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), - **advanced_options, + **additional_options, } ), {"collapsed": True}, @@ -782,8 +782,7 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW options_flow_reloads = True - MINOR_VERSION = 2 - VERSION = 1 + VERSION = 2 @callback @override @@ -901,9 +900,9 @@ def ws_start_preview( return config: dict = msg["user_input"] - advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {}) + additional_options = config.pop(CONF_ADDITIONAL_OPTIONS, {}) preview_entity = CREATE_PREVIEW_ENTITY[template_type]( - hass, name, {**config, **advanced_options} + hass, name, {**config, **additional_options} ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index cbb9c3beb272..816b77b5284d 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.helpers.typing import ConfigType -CONF_ADVANCED_OPTIONS = "advanced_options" +CONF_ADDITIONAL_OPTIONS = "additional_options" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 959fbcb0bc37..66ca4eec45b5 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -31,7 +31,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_ADVANCED_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN +from .const import CONF_ADDITIONAL_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity @@ -240,8 +240,8 @@ async def async_setup_template_entry( options = dict(config_entry.options) options.pop("template_type") - if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None): - options = {**options, **advanced_options} + if additional_options := options.pop(CONF_ADDITIONAL_OPTIONS, None): + options = {**options, **additional_options} if replace_value_template and CONF_VALUE_TEMPLATE in options: options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 6de00e8fdc77..8c9028e03b9f 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,6 +1,6 @@ { "common": { - "advanced_options": "Advanced options", + "additional_options": "Additional options", "availability": "Availability template", "availability_description": "Defines a template to get the `available` state of the entity. If the template either fails to render or returns `True`, `\"1\"`, `\"true\"`, `\"yes\"`, `\"on\"`, `\"enable\"`, or a non-zero number, the entity will be `available`. If the template returns any other value, the entity will be `unavailable`. If not configured, the entity will always be `available`. Note that the string comparison is not case sensitive; `\"TrUe\"` and `\"yEs\"` are allowed.", "code_format": "Code format", @@ -42,14 +42,14 @@ "value_template": "Defines a template to set the state of the alarm panel. Valid output values from the template are `armed_away`, `armed_home`, `armed_night`, `armed_vacation`, `arming`, `disarmed`, `pending`, and `triggered`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template alarm control panel" @@ -66,14 +66,14 @@ "state": "The sensor is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template binary sensor" @@ -90,14 +90,14 @@ "press": "Defines actions to run when button is pressed." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template button" @@ -124,14 +124,14 @@ "stop_cover": "Defines actions to run when the cover is stopped." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template cover" @@ -152,7 +152,7 @@ "name": "[%key:common::config_flow::data::name%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]", "location_accuracy": "Location accuracy" @@ -161,7 +161,7 @@ "availability": "[%key:component::template::common::availability_description%]", "location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`." }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template device tracker" @@ -180,14 +180,14 @@ "event_types": "Defines a template for a list of available event types." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template event" @@ -213,14 +213,14 @@ "turn_on": "Defines actions to run when the fan is turned on. Receives variables `percentage` and/or `preset_mode`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template fan" @@ -238,14 +238,14 @@ "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template image" @@ -277,14 +277,14 @@ "turn_on": "Defines actions to run when the light is turned on." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template light" @@ -308,14 +308,14 @@ "unlock": "Defines actions to run when the lock is unlocked." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template lock" @@ -342,14 +342,14 @@ "unit_of_measurement": "Defines the unit of measurement of the number, if any." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template number" @@ -369,14 +369,14 @@ "state": "Template for the select’s current value." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template select" @@ -396,14 +396,14 @@ "unit_of_measurement": "Defines the unit of measurement for the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template sensor" @@ -423,14 +423,14 @@ "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template switch" @@ -465,14 +465,14 @@ "update_percentage": "Defines a template to get the update completion percentage." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template update" @@ -529,14 +529,14 @@ "stop": "Defines actions to run when the vacuum is stopped." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template vacuum" @@ -562,11 +562,11 @@ "temperature_unit": "The temperature unit" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template weather" @@ -621,14 +621,14 @@ "value_template": "[%key:component::template::config::step::alarm_control_panel::data_description::value_template%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::alarm_control_panel::title%]" @@ -644,14 +644,14 @@ "state": "[%key:component::template::config::step::binary_sensor::data_description::state%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::binary_sensor::title%]" @@ -666,14 +666,14 @@ "press": "[%key:component::template::config::step::button::data_description::press%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::button::title%]" @@ -698,14 +698,14 @@ "stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::cover::title%]" @@ -724,16 +724,16 @@ "longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]", - "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]" + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data::location_accuracy%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]", - "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]" + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data_description::location_accuracy%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::device_tracker::title%]" @@ -751,14 +751,14 @@ "event_types": "[%key:component::template::config::step::event::data_description::event_types%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::event::title%]" @@ -783,14 +783,14 @@ "turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::fan::title%]" @@ -807,14 +807,14 @@ "verify_ssl": "[%key:component::template::config::step::image::data_description::verify_ssl%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::image::title%]" @@ -846,14 +846,14 @@ "turn_on": "[%key:component::template::config::step::light::data_description::turn_on%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::light::title%]" @@ -876,14 +876,14 @@ "unlock": "[%key:component::template::config::step::lock::data_description::unlock%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::lock::title%]" @@ -908,14 +908,14 @@ "step": "[%key:component::template::config::step::number::data_description::step%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::number::title%]" @@ -935,14 +935,14 @@ "state": "[%key:component::template::config::step::select::data_description::state%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::select::title%]" @@ -961,14 +961,14 @@ "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::sensor::title%]" @@ -988,14 +988,14 @@ "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::switch::title%]" @@ -1030,14 +1030,14 @@ "update_percentage": "[%key:component::template::config::step::update::data_description::update_percentage%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template update" @@ -1071,14 +1071,14 @@ "stop": "[%key:component::template::config::step::vacuum::data_description::stop%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::vacuum::title%]" @@ -1104,11 +1104,11 @@ "temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::weather::title%]" diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index f9815ff9f46d..7ae42ab703ea 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -508,10 +508,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - identifier in current_devices for identifier in device_entry.identifiers ): LOGGER.debug("Removing stale device %s", device_entry.id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) entry.runtime_data = TeslemetryData( vehicles=vehicles, diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index a4268319b3bc..a06b9a9610bb 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( HVAC_MODES, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -287,9 +288,15 @@ class TeslemetryStreamingClimateEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") - self._attr_preset_mode = state.attributes.get("preset_mode") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) + self._attr_preset_mode = state.attributes.get( + ClimateEntityStateAttribute.PRESET_MODE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( @@ -531,8 +538,12 @@ class TeslemetryStreamingCabinOverheatProtectionEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 695d73859603..1be37133e03e 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 559f9eab1851..a782ae004538 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -98,9 +98,7 @@ def _remove_old_devices( (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None ) if mac and mac not in omada_devices: - device_registry.async_update_device( - registered_device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(registered_device.id) async def async_migrate_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 45faad54767f..d260410f4338 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -5,8 +5,12 @@ from datetime import timedelta import logging from typing import override -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -16,12 +20,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, TRACKER_UPDATE from .const import ( - ATTR_ACCURACY, ATTR_ALTITUDE, - ATTR_BATTERY, ATTR_BEARING, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_SPEED, EVENT_ALARM, EVENT_ALL_EVENTS, @@ -162,15 +162,17 @@ class TraccarEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_BEARING: attr.get(ATTR_BEARING), ATTR_SPEED: attr.get(ATTR_SPEED), } - self._battery = attr.get(ATTR_BATTERY) + self._battery = attr.get(ATTR_BATTERY_LEVEL) @override async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index c5a8549e91c0..a3f721fe6689 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -34,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 48a7e5212f00..1dc7709a84a1 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -78,9 +78,7 @@ async def cleanup_device_registry( ): for item in device_entry.identifiers: if item[0] == DOMAIN and item[1] not in device_manager.device_map: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) break diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index adb67b35d27b..a17989b44871 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -297,10 +297,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): for identifier in device.identifiers ): continue - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index b4bacac0192d..9b66204c64ea 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -302,18 +302,18 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", translation_key="is_dark", - ufp_value="is_dark", + ufp_public_value="is_dark", ), ProtectBinaryEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, - ufp_value="is_pir_motion_detected", + ufp_public_value="is_pir_motion_detected", ), ProtectBinaryEntityDescription( key="light", translation_key="flood_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="is_light_on", + ufp_public_value="is_light_on", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -328,7 +328,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="status_light", translation_key="status_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.is_indicator_enabled", + ufp_public_value="light_device_settings.is_indicator_enabled", ufp_perm=PermRequired.NO_WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 1dd90b65079d..e5e42b5bb65e 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,10 +1,11 @@ """Component providing Lights for UniFi Protect.""" import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel from uiprotect.data.devices import LightDeviceSettings +from uiprotect.data.public_devices import PublicLight from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback @@ -61,14 +62,29 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness") + @override + async def async_added_to_hass(self) -> None: + """Read state from the public API (primed before the first update).""" + self._ufp_uses_public = True + self._ufp_public_obj = self.data.async_get_public_device(self.device) + self.async_on_remove( + self.data.async_subscribe_public( + self.device.mac, self._async_public_updated + ) + ) + await super().async_added_to_hass() + @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) - updated_device = self.device - self._attr_is_on = updated_device.is_light_on - self._attr_brightness = unifi_brightness_to_hass( - updated_device.light_device_settings.led_level + if (public := self._ufp_public_obj) is None: + return + light = cast(PublicLight, public) + self._attr_is_on = light.is_light_on + led_level = light.light_device_settings.led_level + self._attr_brightness = ( + None if led_level is None else unifi_brightness_to_hass(led_level) ) @async_ufp_instance_command diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b279a5015c30..5d2673998cef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.12.1"] + "requirements": ["uiprotect==15.14.2"] } diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 8ed230acdf89..d2a94eb53b38 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -148,11 +148,7 @@ def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> N for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): if device.model_id != _AIPORT_DEVICE_TYPE: continue - # Detaching the config entry removes the device (it has no other entry) - # and its entities along with it. - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) @callback diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index fd95c888b16e..2f7cf224e209 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -173,8 +173,8 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_value="light_device_settings.pir_sensitivity", - ufp_set_method="set_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", + ufp_set_method="set_sensitivity_public", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7789a3face2f..888267d346a5 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -51,7 +51,7 @@ from .entity import ( async_all_device_entities, async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current, async_ufp_instance_command +from .utils import async_get_light_motion_current_public, async_ufp_instance_command _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -173,7 +173,7 @@ def _get_doorbell_current(obj: Camera) -> str | None: async def _set_light_mode(obj: Light, mode: str) -> None: lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode] - await obj.set_light_settings( + await obj.set_light_mode_public( LightModeType(lightmode), enable_at=None if timing is None else LightModeEnableType(timing), ) @@ -308,7 +308,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( translation_key="light_mode", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_set_method_fn=_set_light_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 49d1e8a3fd32..cb3896cbd2c4 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from functools import partial import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import ( NVR, @@ -16,7 +16,12 @@ from uiprotect.data import ( ProtectDeviceModel, Sensor, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) +from uiprotect.utils import convert_to_datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -52,7 +57,7 @@ from .entity import ( async_all_device_entities, async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current +from .utils import async_get_light_motion_current_public _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -90,6 +95,11 @@ class ProtectSensorEventEntityDescription( """Describes UniFi Protect Sensor entity.""" +def _get_last_motion_public(obj: PublicDeviceModel) -> datetime | None: + # Public API reports last motion as a JS epoch (ms); private side a datetime. + return convert_to_datetime(cast(PublicLight, obj).last_motion) + + def _get_uptime(obj: ProtectDeviceModel) -> datetime | None: if obj.up_since is None: return None @@ -508,7 +518,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="last_motion", + ufp_public_value_fn=_get_last_motion_public, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -516,14 +526,14 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="motion_sensitivity", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.pir_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription[Light]( key="light_motion", translation_key="light_mode", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f7f664c9d92c..54812f75882e 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -387,8 +387,8 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( key="status_light", translation_key="status_light", entity_category=EntityCategory.CONFIG, - ufp_value="light_device_settings.is_indicator_enabled", - ufp_set_method="set_status_light", + ufp_public_value="light_device_settings.is_indicator_enabled", + ufp_set_method="set_status_light_public", ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 933c0f9b6e8d..fc411102f607 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -5,18 +5,18 @@ import contextlib from functools import wraps from pathlib import Path import socket -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import CookieJar from uiprotect import ProtectApiClient from uiprotect.data import ( Bootstrap, ChannelQuality, - Light, LightModeEnableType, LightModeType, ProtectAdoptableDeviceModel, ) +from uiprotect.data.public_devices import PublicDeviceModel, PublicLight from uiprotect.exceptions import ClientError, NotAuthorized from homeassistant.const import ( @@ -95,15 +95,14 @@ def async_get_devices( @callback -def async_get_light_motion_current(obj: Light) -> str: - """Get light motion mode for Flood Light.""" - - if ( - obj.light_mode_settings.mode is LightModeType.MOTION - and obj.light_mode_settings.enable_at is LightModeEnableType.DARK - ): +def async_get_light_motion_current_public(obj: PublicDeviceModel) -> str | None: + """Get light motion mode for a Flood Light from the public API.""" + settings = cast(PublicLight, obj).light_mode_settings + if (mode := settings.mode) is None: + return None + if mode is LightModeType.MOTION and settings.enable_at is LightModeEnableType.DARK: return f"{LightModeType.MOTION.value}_dark" - return obj.light_mode_settings.mode.value + return mode.value @callback diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index a0e2c77341c6..8fb244b18df8 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -205,7 +205,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ddad80b92f24..903280052d00 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==1.0.3"] + "requirements": ["pytrydan==1.0.4"] } diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 4e8f0f0bfc4f..5cc742925995 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -138,22 +138,18 @@ async def async_remove_config_entry_device( config_entry: VelbusConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: - """Allow removing a Velbus device and detach its sub-devices. + """Allow removing a Velbus device and its sub-devices. - Sub-devices are detached from this config entry when their parent is - removed. If the device is still on the bus, it may be recreated when - the integration is reloaded or started again. + Sub-devices are removed along with their parent. If the device is still + on the bus, it may be recreated when the integration is reloaded or + started again. """ if config_entry.entry_id not in device_entry.config_entries: return False dev_reg = dr.async_get(hass) for sub_device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id): if sub_device.via_device_id == device_entry.id: - dev_reg.async_update_device( - sub_device.id, - remove_config_entry_id=config_entry.entry_id, - via_device_id=None, - ) + dev_reg.async_remove_device(sub_device.id) return True diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index b8f5a1195404..681eedfef4c9 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -45,7 +46,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "entry": config_entry.as_dict(), diff --git a/homeassistant/components/vibration/__init__.py b/homeassistant/components/vibration/__init__.py new file mode 100644 index 000000000000..b361746282f3 --- /dev/null +++ b/homeassistant/components/vibration/__init__.py @@ -0,0 +1,15 @@ +"""Integration for vibration triggers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "vibration" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/vibration/condition.py b/homeassistant/components/vibration/condition.py new file mode 100644 index 000000000000..ab43c4593db5 --- /dev/null +++ b/homeassistant/components/vibration/condition.py @@ -0,0 +1,25 @@ +"""Provides conditions for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for vibration.""" + return CONDITIONS diff --git a/homeassistant/components/vibration/conditions.yaml b/homeassistant/components/vibration/conditions.yaml new file mode 100644 index 000000000000..5f5bb66d8aaa --- /dev/null +++ b/homeassistant/components/vibration/conditions.yaml @@ -0,0 +1,26 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + +is_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +is_not_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json new file mode 100644 index 000000000000..d51de741bf87 --- /dev/null +++ b/homeassistant/components/vibration/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_detected": { + "condition": "mdi:vibrate" + }, + "is_not_detected": { + "condition": "mdi:vibrate-off" + } + }, + "triggers": { + "cleared": { + "trigger": "mdi:vibrate-off" + }, + "detected": { + "trigger": "mdi:vibrate" + } + } +} diff --git a/homeassistant/components/vibration/manifest.json b/homeassistant/components/vibration/manifest.json new file mode 100644 index 000000000000..e875b7c6c583 --- /dev/null +++ b/homeassistant/components/vibration/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "vibration", + "name": "Vibration", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/vibration", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json new file mode 100644 index 000000000000..b1b3898251cf --- /dev/null +++ b/homeassistant/components/vibration/strings.json @@ -0,0 +1,61 @@ +{ + "common": { + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "conditions": { + "is_detected": { + "description": "Tests if one or more vibration sensors are detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is detected" + }, + "is_not_detected": { + "description": "Tests if one or more vibration sensors are not detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is not detected" + } + }, + "title": "Vibration", + "triggers": { + "cleared": { + "description": "Triggers when one or more vibration sensors stop detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration cleared" + }, + "detected": { + "description": "Triggers when one or more vibration sensors start detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration detected" + } + } +} diff --git a/homeassistant/components/vibration/trigger.py b/homeassistant/components/vibration/trigger.py new file mode 100644 index 000000000000..a23a62401660 --- /dev/null +++ b/homeassistant/components/vibration/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_ON), + "cleared": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for vibration.""" + return TRIGGERS diff --git a/homeassistant/components/vibration/triggers.yaml b/homeassistant/components/vibration/triggers.yaml new file mode 100644 index 000000000000..0957393172c3 --- /dev/null +++ b/homeassistant/components/vibration/triggers.yaml @@ -0,0 +1,26 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 55ba55642566..78e66edf31af 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.60.2"] + "requirements": ["PyViCare==2.61.0"] } diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 61908b84b513..a14b7b43f7ff 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["victron-mqtt==2026.7.0"], + "requirements": ["victron-mqtt==2026.7.4"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 2f88854cfd89..dc4cd29e0326 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -403,6 +403,13 @@ "passthrough": "[%key:component::victron_gx::common::passthrough%]" } }, + "battery_bms_mode": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "standby": "[%key:common::state::standby%]" + } + }, "evcharger_mode": { "name": "[%key:common::config_flow::data::mode%]", "state": { @@ -1899,6 +1906,16 @@ "system_heartbeat": { "name": "GX system heartbeat" }, + "system_pv_on_grid_current_phase": { + "name": "PV on grid current {phase}" + }, + "system_pv_on_grid_phases": { + "name": "PV on grid phases", + "unit_of_measurement": "phases" + }, + "system_pv_on_grid_power_phase": { + "name": "PV on grid power {phase}" + }, "system_pv_on_output_current_phase": { "name": "PV on output current {phase}" }, diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 1f34877ab995..d7a01cbd6578 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -150,9 +150,7 @@ def _async_remove_stale_devices( for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): pool_id = next((i[1] for i in device.identifiers if i[0] == DOMAIN), None) if pool_id is not None and pool_id not in valid_pool_ids: - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_initial_refresh( diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index a7e7ca3d7354..d8c1979fc0ed 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -2,7 +2,6 @@ import copy import logging -import socket from typing import Any, override from pyvizio import VizioAsync, async_guess_device_type @@ -29,7 +28,6 @@ from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from homeassistant.util.network import is_ip_address from . import DATA_APPS from .const import ( @@ -95,15 +93,6 @@ def _get_pairing_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema: ) -def _host_is_same(host1: str, host2: str) -> bool: - """Check if host1 and host2 are the same.""" - host1 = host1.split(":", maxsplit=1)[0] - host1 = host1 if is_ip_address(host1) else socket.gethostbyname(host1) - host2 = host2.split(":", maxsplit=1)[0] - host2 = host2 if is_ip_address(host2) else socket.gethostbyname(host2) - return host1 == host2 - - class VizioOptionsConfigFlow(OptionsFlow): """Handle Vizio options.""" @@ -294,7 +283,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(unique_id=unique_id, raise_on_progress=True) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) # Form must be shown after discovery so user can confirm/update configuration # before ConfigEntry creation. diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index bf191e5b6c6f..41b60a6bd823 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @@ -126,10 +126,10 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if device is not None: - # Device and entity registries don't update the disabled_by flag when - # moving a device or entity from one config entry to another, so we - # need to do it manually. - device_disabled_by = device.disabled_by + # The device registry will set the disabled_by flag to None when + # moving a device disabled by CONFIG_ENTRY to an enabled config + # entry, but we want to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -138,20 +138,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device_registry.async_update_device( device.id, disabled_by=device_disabled_by, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if parent_entry.entry_id != entry.entry_id: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/wattwaechter/config_flow.py b/homeassistant/components/wattwaechter/config_flow.py index 7344d115a6ba..2de2f5a70d15 100644 --- a/homeassistant/components/wattwaechter/config_flow.py +++ b/homeassistant/components/wattwaechter/config_flow.py @@ -21,6 +21,11 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_FW_VERSION, DOMAIN @@ -247,3 +252,42 @@ class WattwaechterConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={"host": reauth_entry.data[CONF_HOST]}, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the host and token.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + # Normalize a cleared token field to None, matching how token-less + # devices are stored everywhere else in the integration. + token = user_input.get(CONF_TOKEN) or None + errors, system_info, _ = await self._async_test_connection( + user_input[CONF_HOST], token + ) + if not errors: + assert system_info is not None + await self.async_set_unique_id(system_info.get_value("esp", "esp_id")) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_HOST: user_input[CONF_HOST], CONF_TOKEN: token}, + ) + + schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + schema, user_input or reconfigure_entry.data + ), + errors=errors, + ) diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml index a831a42304cf..ead33c6cdb41 100644 --- a/homeassistant/components/wattwaechter/quality_scale.yaml +++ b/homeassistant/components/wattwaechter/quality_scale.yaml @@ -68,7 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No actionable repair scenarios for this device type. diff --git a/homeassistant/components/wattwaechter/strings.json b/homeassistant/components/wattwaechter/strings.json index adc7091b87ec..b314a97721cd 100644 --- a/homeassistant/components/wattwaechter/strings.json +++ b/homeassistant/components/wattwaechter/strings.json @@ -4,7 +4,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_device": "The re-authenticated device does not match the original WattWächter Plus device." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The device does not match the original WattWächter Plus device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -31,6 +32,17 @@ "description": "The API token for {host} is no longer valid. Enter a new token to reconnect.", "title": "Re-authenticate WattWächter Plus" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "host": "[%key:component::wattwaechter::config::step::user::data_description::host%]", + "token": "[%key:component::wattwaechter::config::step::auth::data_description::token%]" + }, + "title": "Reconfigure WattWächter Plus" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 4f74c34e7a50..2724a87d3079 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -22,6 +22,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.LIGHT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index eb6a5759b7fc..ec98253e9716 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -75,6 +75,15 @@ class WhirlpoolOvenEntity(WhirlpoolEntity): _appliance: Oven + @staticmethod + def cavity_suffix(oven: Oven, cavity: OvenCavity) -> str: + """Return the unique-id and translation-key suffix for an oven cavity.""" + if oven.get_oven_cavity_exists( + OvenCavity.Upper + ) and oven.get_oven_cavity_exists(OvenCavity.Lower): + return "_upper" if cavity == OvenCavity.Upper else "_lower" + return "" + def __init__( self, appliance: Oven, @@ -84,14 +93,7 @@ class WhirlpoolOvenEntity(WhirlpoolEntity): ) -> None: """Initialize the entity.""" self.cavity = cavity - cavity_suffix = "" - if appliance.get_oven_cavity_exists( - OvenCavity.Upper - ) and appliance.get_oven_cavity_exists(OvenCavity.Lower): - if cavity == OvenCavity.Upper: - cavity_suffix = "_upper" - elif cavity == OvenCavity.Lower: - cavity_suffix = "_lower" + cavity_suffix = self.cavity_suffix(appliance, cavity) super().__init__( appliance, unique_id_suffix=f"{unique_id_suffix}{cavity_suffix}" ) diff --git a/homeassistant/components/whirlpool/number.py b/homeassistant/components/whirlpool/number.py new file mode 100644 index 000000000000..a76c44014504 --- /dev/null +++ b/homeassistant/components/whirlpool/number.py @@ -0,0 +1,79 @@ +"""Number platform for the Whirlpool Appliances integration.""" + +from typing import override + +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .const import DOMAIN +from .entity import WhirlpoolOvenEntity + +PARALLEL_UPDATES = 1 + +# Oven target temperatures are handled in Celsius. The appliance accepts +# tenth-of-a-degree values, so a 1-degree step gives fine manual control while +# automations can still set any value Home Assistant passes through. +OVEN_MIN_TEMP = 30 +OVEN_MAX_TEMP = 290 +OVEN_TEMP_STEP = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the number platform.""" + appliances_manager = config_entry.runtime_data + async_add_entities( + WhirlpoolOvenTargetTemperature(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + ) + + +class WhirlpoolOvenTargetTemperature(WhirlpoolOvenEntity, NumberEntity): + """Settable target temperature for an oven cavity.""" + + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_native_min_value = OVEN_MIN_TEMP + _attr_native_max_value = OVEN_MAX_TEMP + _attr_native_step = OVEN_TEMP_STEP + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven target temperature number.""" + super().__init__( + appliance, cavity, "oven_target_temperature", "-target_temperature" + ) + + @override + @property + def native_value(self) -> float | None: + """Return the current target temperature.""" + return self._appliance.get_target_temp(self.cavity) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set a new target temperature, keeping the current cook mode.""" + mode = self._appliance.get_cook_mode(self.cavity) + if mode is None or mode == CookMode.Standby: + mode = CookMode.Bake + try: + WhirlpoolOvenTargetTemperature._check_service_request( + await self._appliance.set_cook( + target_temp=value, mode=mode, cavity=self.cavity + ) + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml index 2f75dd42e1aa..1a444ee0f4e5 100644 --- a/homeassistant/components/whirlpool/quality_scale.yaml +++ b/homeassistant/components/whirlpool/quality_scale.yaml @@ -74,9 +74,7 @@ rules: comment: | Time remaining sensor still has hardcoded icon. reconfiguration-flow: todo - repair-issues: - status: exempt - comment: No known use cases for repair issues or flows, yet + repair-issues: done stale-devices: todo # Platinum diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py index 9bac108976a8..6df32716ab69 100644 --- a/homeassistant/components/whirlpool/select.py +++ b/homeassistant/components/whirlpool/select.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Final, override from whirlpool.appliance import Appliance +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import UnitOfTemperature @@ -14,10 +15,26 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry from .const import DOMAIN -from .entity import WhirlpoolEntity +from .entity import WhirlpoolEntity, WhirlpoolOvenEntity PARALLEL_UPDATES = 1 +OVEN_COOK_MODES: Final[dict[CookMode, str]] = { + CookMode.Standby: "standby", + CookMode.Bake: "bake", + CookMode.ConvectBake: "convection_bake", + CookMode.Broil: "broil", + CookMode.ConvectBroil: "convection_broil", + CookMode.ConvectRoast: "convection_roast", + CookMode.KeepWarm: "keep_warm", + CookMode.AirFry: "air_fry", +} +OPTION_TO_OVEN_COOK_MODE: Final = {v: k for k, v in OVEN_COOK_MODES.items()} + +# Target temperature (Celsius) used when a mode is selected while the oven is +# idle and has no target set yet. +DEFAULT_OVEN_TEMP = 175 + @dataclass(frozen=True, kw_only=True) class WhirlpoolSelectDescription(SelectEntityDescription): @@ -49,11 +66,18 @@ async def async_setup_entry( """Set up the select platform.""" appliances_manager = config_entry.runtime_data - async_add_entities( + entities: list[SelectEntity] = [ WhirlpoolSelectEntity(refrigerator, description) for refrigerator in appliances_manager.refrigerators for description in REFRIGERATOR_DESCRIPTIONS + ] + entities.extend( + WhirlpoolOvenCookModeSelect(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) ) + async_add_entities(entities) class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): @@ -84,3 +108,44 @@ class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): translation_domain=DOMAIN, translation_key="invalid_value_set", ) from err + + +class WhirlpoolOvenCookModeSelect(WhirlpoolOvenEntity, SelectEntity): + """Settable cook mode for an oven cavity.""" + + _attr_options = list(OVEN_COOK_MODES.values()) + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven cook mode select.""" + super().__init__(appliance, cavity, "oven_cook_mode", "-cook_mode") + + @override + @property + def current_option(self) -> str | None: + """Return the current cook mode, if it is a selectable one.""" + return OVEN_COOK_MODES.get(self._appliance.get_cook_mode(self.cavity)) + + @override + async def async_select_option(self, option: str) -> None: + """Set the cook mode, keeping the current/last target temperature.""" + mode = OPTION_TO_OVEN_COOK_MODE[option] + try: + if mode == CookMode.Standby: + # Standby is the idle state: the oven reaches it by cancelling + # the current cook, not by starting a "standby" cook. + result = await self._appliance.stop_cook(self.cavity) + else: + target = self._appliance.get_target_temp(self.cavity) + if target is None: + target = DEFAULT_OVEN_TEMP + result = await self._appliance.set_cook( + target_temp=target, + mode=mode, + cavity=self.cavity, + ) + WhirlpoolOvenCookModeSelect._check_service_request(result) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index e7df831bb7d7..14c695db99fb 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -23,14 +23,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity, WhirlpoolOvenEntity +from .util import deprecate_entity PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(minutes=5) @@ -256,6 +258,34 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = ( ), ) +# Sensors replaced by more capable entities (select and number respectively). +DEPRECATED_OVEN_SENSOR_KEYS = ("oven_cook_mode", "oven_target_temperature") + + +def _build_oven_cavity_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + oven: Oven, + cavity: OvenCavity, +) -> list[SensorEntity]: + """Build the sensors for a single oven cavity, handling deprecations.""" + suffix = WhirlpoolOvenEntity.cavity_suffix(oven, cavity) + sensors: list[SensorEntity] = [] + for description in OVEN_CAVITY_SENSORS: + # The oven cook mode and target temperature sensors have been replaced + # by select and number entities respectively. + if description.key in DEPRECATED_OVEN_SENSOR_KEYS and not deprecate_entity( + hass, + entity_registry, + platform_domain=Platform.SENSOR, + entity_unique_id=f"{oven.said}-{description.key}{suffix}", + issue_id=f"deprecated_{description.key}_{oven.said}{suffix}", + translation_key=f"deprecated_{description.key}", + ): + continue + sensors.append(WhirlpoolOvenCavitySensor(oven, cavity, description)) + return sensors + async def async_setup_entry( hass: HomeAssistant, @@ -289,18 +319,13 @@ async def async_setup_entry( for description in WASHER_DRYER_TIME_SENSORS ] - oven_upper_cavity_sensors = [ - WhirlpoolOvenCavitySensor(oven, OvenCavity.Upper, description) + entity_registry = er.async_get(hass) + oven_cavity_sensors = [ + sensor for oven in appliances_manager.ovens - if oven.get_oven_cavity_exists(OvenCavity.Upper) - for description in OVEN_CAVITY_SENSORS - ] - - oven_lower_cavity_sensors = [ - WhirlpoolOvenCavitySensor(oven, OvenCavity.Lower, description) - for oven in appliances_manager.ovens - if oven.get_oven_cavity_exists(OvenCavity.Lower) - for description in OVEN_CAVITY_SENSORS + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + for sensor in _build_oven_cavity_sensors(hass, entity_registry, oven, cavity) ] async_add_entities( @@ -309,8 +334,7 @@ async def async_setup_entry( *washer_time_sensors, *dryer_sensors, *dryer_time_sensors, - *oven_upper_cavity_sensors, - *oven_lower_cavity_sensors, + *oven_cavity_sensors, ] ) @@ -332,29 +356,29 @@ class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): - """Abstract base class for Whirlpool washer/dryer time sensors.""" +class WhirlpoolTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool end-time timestamp sensors.""" _attr_should_poll = True - _appliance: Washer | Dryer - def __init__( - self, appliance: Washer | Dryer, description: SensorEntityDescription - ) -> None: - """Initialize the washer/dryer sensor.""" - super().__init__(appliance, unique_id_suffix=f"-{description.key}") - self.entity_description = description + def __init__(self, appliance: Appliance, unique_id_suffix: str) -> None: + """Initialize the time sensor.""" + super().__init__(appliance, unique_id_suffix=unique_id_suffix) self._running: bool | None = None self._value: datetime | None = None @abstractmethod - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: + """Return true if the timer/cycle is in a finished state.""" @abstractmethod - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: + """Return true if the timer/cycle is in a running state.""" + + @abstractmethod + def _get_seconds_remaining(self) -> int: + """Return the number of seconds remaining.""" @override async def async_added_to_hass(self) -> None: @@ -368,21 +392,19 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): """Update status of Whirlpool.""" await self._appliance.fetch_data() - @override @property + @override def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" now = utcnow() - if self._is_machine_state_finished() and self._running: + if self._is_finished() and self._running: self._running = False self._value = now - if self._is_machine_state_running(): + if self._is_running(): self._running = True - new_timestamp = now + timedelta( - seconds=self._appliance.get_time_remaining() - ) + new_timestamp = now + timedelta(seconds=self._get_seconds_remaining()) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) @@ -391,45 +413,59 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): return self._value -class WasherTimeSensor(WasherDryerTimeSensorBase): +class WasherTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool washers.""" _appliance: Washer + def __init__(self, appliance: Washer, description: SensorEntityDescription) -> None: + """Initialize the washer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { WasherMachineState.Complete, WasherMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return ( self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle ) + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() -class DryerTimeSensor(WasherDryerTimeSensorBase): + +class DryerTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool dryers.""" _appliance: Dryer + def __init__(self, appliance: Dryer, description: SensorEntityDescription) -> None: + """Initialize the dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { DryerMachineState.Complete, DryerMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() + class WhirlpoolOvenCavitySensor(WhirlpoolOvenEntity, SensorEntity): """A class for Whirlpool oven cavity sensors.""" diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 5c5581afa5fa..75a70e8e89b4 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -68,7 +68,57 @@ "name": "Upper oven light" } }, + "number": { + "oven_target_temperature": { + "name": "Target temperature" + }, + "oven_target_temperature_lower": { + "name": "Lower oven target temperature" + }, + "oven_target_temperature_upper": { + "name": "Upper oven target temperature" + } + }, "select": { + "oven_cook_mode": { + "name": "Cook mode", + "state": { + "air_fry": "Air fry", + "bake": "Bake", + "broil": "Broil", + "convection_bake": "Convection bake", + "convection_broil": "Convection broil", + "convection_roast": "Convection roast", + "keep_warm": "Keep warm", + "standby": "[%key:common::state::standby%]" + } + }, + "oven_cook_mode_lower": { + "name": "Lower oven cook mode", + "state": { + "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]", + "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]", + "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]", + "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]", + "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]", + "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]", + "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]", + "standby": "[%key:common::state::standby%]" + } + }, + "oven_cook_mode_upper": { + "name": "Upper oven cook mode", + "state": { + "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]", + "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]", + "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]", + "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]", + "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]", + "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]", + "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]", + "standby": "[%key:common::state::standby%]" + } + }, "refrigerator_temperature_level": { "name": "Temperature level" } @@ -250,5 +300,23 @@ "request_failed": { "message": "Request failed" } + }, + "issues": { + "deprecated_oven_cook_mode": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nUpdate any dashboards, templates, automations or scripts to use the new select entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven cook mode sensor is deprecated" + }, + "deprecated_oven_cook_mode_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new select entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_cook_mode::title%]" + }, + "deprecated_oven_target_temperature": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nUpdate any dashboards, templates, automations or scripts to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven target temperature sensor is deprecated" + }, + "deprecated_oven_target_temperature_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_target_temperature::title%]" + } } } diff --git a/homeassistant/components/whirlpool/util.py b/homeassistant/components/whirlpool/util.py new file mode 100644 index 000000000000..a2dc9c6a7ebc --- /dev/null +++ b/homeassistant/components/whirlpool/util.py @@ -0,0 +1,101 @@ +"""Utility helpers for the Whirlpool 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 = "2026.12.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/withings/sensor.py b/homeassistant/components/withings/sensor.py index 520c89e7f399..73b56a8dae35 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -850,17 +850,22 @@ async def async_setup_entry( if new_devices: device_registry = dr.async_get(hass) for device_id in new_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - if any( - ( - config_entry := hass.config_entries.async_get_entry( - config_entry_id - ) + # The same sub-device can be reported by several config entries, each + # owning its own device registry entry. Its sensors share a unique id + # across config entries, so only create them if no other loaded config + # entry already provides them. + if any( + ( + config_entry := hass.config_entries.async_get_entry( + device.config_entry_id ) - and config_entry.state is ConfigEntryState.LOADED - for config_entry_id in device.config_entries - ): - continue + ) + and config_entry.state is ConfigEntryState.LOADED + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ) + ): + continue async_add_entities( WithingsDeviceSensor(device_coordinator, description, device_id) for description in DEVICE_SENSORS @@ -870,11 +875,17 @@ async def async_setup_entry( if old_devices: device_registry = dr.async_get(hass) for device_id in old_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - current_devices.remove(device_id) + # Several config entries can share this identifier, each owning its own + # device registry entry, so only remove this entry's own device. + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ): + if device.config_entry_id == entry.entry_id: + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + break + current_devices.remove(device_id) device_coordinator.async_add_listener(_async_device_listener) diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index d86047e323dc..1a94fc700194 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN, MANUFACTURER from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator @@ -171,22 +172,21 @@ def _reattach_device_to_hub( if device is None: return - device_disabled_by = device.disabled_by - if device_disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY: + # The device registry will set the disabled_by flag to None when moving a + # device disabled by CONFIG_ENTRY to an enabled config entry, but we want + # to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and hub_entry.disabled_by is None + ): device_disabled_by = dr.DeviceEntryDisabler.USER - if source_entry.entry_id != hub_entry.entry_id: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - add_config_entry_id=hub_entry.entry_id, - remove_config_entry_id=source_entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - ) + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_config_entry_id=hub_entry.entry_id, + ) for entity_entry in er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 368157a22c2b..d9b0766d70eb 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -127,9 +127,7 @@ class XboxConsolesCoordinator(XboxBaseCoordinator[dict[str, SmartglassConsole]]) and not set(device.identifiers) & identifiers ): _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return {console.id: console for console in consoles.result} diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index a1917c847870..c2404ea1419c 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -169,9 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bo identifier[0] == DOMAIN and device_coordinators.get(identifier[1]) is None ): - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py index 026b00eb11ed..b3d94b1bb4af 100644 --- a/homeassistant/components/yoto/coordinator.py +++ b/homeassistant/components/yoto/coordinator.py @@ -161,9 +161,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): (ident[1] for ident in device.identifiers if ident[0] == DOMAIN), None ) if player_id is not None and player_id not in self.client.players: - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_load_library(self) -> None: """Load the card library and groups; failures only affect browsing.""" diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index dff9652398d7..b6f12c7efc9c 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -71,6 +71,4 @@ async def delete_devices( dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for dev_entry in dev_entries: if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): - device_registry.async_update_device( - dev_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(dev_entry.id) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 180a95286afe..7112914208f5 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==2.0.0", "zha-quirks==2.1.1"], + "requirements": ["zha==2.0.1", "zha-quirks==2.1.1"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2edbb3c035f8..9f1e2839be95 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2133,6 +2133,7 @@ class ConfigEntries: self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) + self._initialized = asyncio.Event() EntityRegistryDisabledHandler(hass).async_setup() @callback @@ -2277,7 +2278,7 @@ class ConfigEntries: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_entry(entry_id) + dev_reg.async_clear_config_entry(entry_id, entry.domain) ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should @@ -2302,6 +2303,7 @@ class ConfigEntries: if config is None: self._entries = ConfigEntryItems(self.hass) + self._initialized.set() return entries: ConfigEntryItems = ConfigEntryItems(self.hass) @@ -2341,6 +2343,12 @@ class ConfigEntries: EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries ) + self._initialized.set() + + async def async_wait_initialized(self) -> None: + """Wait until the config entries are loaded from storage.""" + await self._initialized.wait() + async def _async_scan_orphan_ignored_entries( self, event: Event[NoEventData] ) -> None: @@ -2686,7 +2694,7 @@ class ConfigEntries: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id, entry.domain) ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id) return result diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d6176cabdfd7..a2157d6ebe46 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ FLOWS = { "fyta", "garages_amsterdam", "gardena_bluetooth", + "gatus", "gdacs", "generic", "geniushub", @@ -306,6 +307,7 @@ FLOWS = { "guntamatic", "habitica", "hanna", + "harbor", "harman_luxury", "harmony", "hdfury", @@ -413,6 +415,7 @@ FLOWS = { "ld2410_ble", "leaone", "led_ble", + "led_infrared", "lektrico", "letpot", "lg_infrared", @@ -428,6 +431,7 @@ FLOWS = { "lifx", "linkplay", "litejet", + "litellm", "litterrobot", "livisi", "llama_cpp", @@ -477,7 +481,6 @@ FLOWS = { "mjpeg", "moat", "mobile_app", - "modbus_connection", "modem_callerid", "modern_forms", "moehlenhoff_alpha2", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ba2775542fff..7cf55be9ec3a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2388,6 +2388,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "gatus": { + "name": "Gatus", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "gaviota": { "name": "Gaviota", "integration_type": "virtual", @@ -2752,6 +2758,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "harbor": { + "name": "Harbor Sleep", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "hardkernel": { "name": "Hardkernel", "integration_type": "hardware", @@ -3722,6 +3734,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "led_infrared": { + "name": "LED Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "legrand": { "name": "Legrand", "integration_type": "virtual", @@ -3905,6 +3923,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "litellm": { + "name": "LiteLLM", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "litterrobot": { "name": "Whisker", "integration_type": "hub", @@ -4406,16 +4430,11 @@ "iot_class": "local_polling" }, "modbus": { + "name": "Modbus", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" }, - "modbus_connection": { - "name": "Modbus Connection", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "modem_callerid": { "name": "Phone Modem", "integration_type": "device", @@ -8548,7 +8567,6 @@ "local_todo", "min_max", "mobile_app", - "modbus", "moehlenhoff_alpha2", "mold_indicator", "moon", diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index 2d90a9c7914b..af8e5908661f 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -3,6 +3,7 @@ from homeassistant.core import HomeAssistant, callback from . import device_registry as dr, entity_registry as er +from .frame import ReportBehavior, report_usage @callback @@ -41,13 +42,18 @@ def async_device_info_to_link_from_entity( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from an entity. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - return async_device_info_to_link_from_device_id( - hass, - async_entity_id_to_device_id(hass, entity_id_or_uuid), + report_usage( + "calls async_device_info_to_link_from_entity, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry = " + "async_entity_id_to_device(hass, source_entity_id) instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback @@ -57,18 +63,17 @@ def async_device_info_to_link_from_device_id( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from a device id. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - dev_reg = dr.async_get(hass) - - if device_id is None or (device := dev_reg.async_get(device_id=device_id)) is None: - return None - - return dr.DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, + report_usage( + "calls async_device_info_to_link_from_device_id, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry to the target device instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index daf373a18624..10c015f5520d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,11 +2,15 @@ import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Set as AbstractSet +import copy +from dataclasses import dataclass from datetime import datetime from enum import StrEnum from functools import lru_cache import logging +import os +import shutil import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack, override @@ -32,7 +36,12 @@ from homeassistant.util.json import format_unserializable_data from . import storage, translation from .debounce import Debouncer from .deprecation import deprecated_function -from .frame import ReportBehavior, report_usage +from .frame import ( + MissingIntegrationFrame, + ReportBehavior, + get_integration_frame, + report_usage, +) from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .typing import UNDEFINED, UndefinedType @@ -54,8 +63,8 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event "device_registry_updated" ) STORAGE_KEY = "core.device_registry" -STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 12 +STORAGE_VERSION_MAJOR = 3 +STORAGE_VERSION_MINOR = 1 CLEANUP_DELAY = 10 @@ -66,8 +75,32 @@ CONNECTION_ZIGBEE = "zigbee" ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 -# Can be removed when suggested_area is removed from DeviceEntry -RUNTIME_ONLY_ATTRS = {"suggested_area"} +# suggested_area can be removed when suggested_area is removed from DeviceEntry. +# pending_move can be removed once add_config_entry_id and remove_config_entry_id +# are removed from the device registry API. +RUNTIME_ONLY_ATTRS = {"suggested_area", "pending_move"} + + +@dataclass(frozen=True, slots=True) +class _PendingMove: + """A deferred config-entry move recorded by add_config_entry_id. + + A later remove_config_entry_id from the same integration (origin_domain) completes + the move; one from a different integration cancels it. Runtime-only, never stored. + """ + + config_entry_id: str + config_subentry_id: str | None + origin_domain: str | None + + +def _current_integration_domain() -> str | None: + """Return the domain of the integration in the current call stack, if any.""" + try: + return get_integration_frame().integration + except MissingIntegrationFrame: + return None + CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} @@ -102,7 +135,8 @@ class DeviceInfo(TypedDict, total=False): hw_version: str | None translation_key: str | None translation_placeholders: Mapping[str, str] | None - via_device: tuple[str, str] + via_device: tuple[str, str] # Deprecated, use via_device_id instead + via_device_id: str DEVICE_INFO_TYPES = { @@ -127,6 +161,7 @@ DEVICE_INFO_TYPES = { "suggested_area", "sw_version", "via_device", + "via_device_id", }, "secondary": { "connections", @@ -135,14 +170,10 @@ DEVICE_INFO_TYPES = { "default_name", # Used by Fritz "via_device", + "via_device_id", }, } -DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) - -# Integrations which may share a device with a native integration -LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} - class _EventDeviceRegistryUpdatedData_Create(TypedDict): """EventDeviceRegistryUpdated data for action type 'create'.""" @@ -365,9 +396,10 @@ def _normalize_connections_validator( class DeviceEntry: """Device Registry Entry.""" + config_entry_id: str = attr.ib() + area_id: str | None = attr.ib(default=None) - config_entries: set[str] = attr.ib(converter=set, factory=set) - config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) + config_subentry_id: str | None = attr.ib(default=None) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib( converter=set, factory=set, validator=_normalize_connections_validator @@ -379,20 +411,85 @@ class DeviceEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) labels: set[str] = attr.ib(converter=set, factory=set) + # composite_device_id is the id of the pre-migration composite device this device was + # split from; composite_primary_config_entry is that composite's former + # primary_config_entry, so a restored composite device can report it. + # split_at records when the split happened. + composite_device_id: str | None = attr.ib(default=None) + composite_primary_config_entry: str | None = attr.ib(default=None) + split_at: datetime | None = attr.ib(default=None) manufacturer: str | None = attr.ib(default=None) model: str | None = attr.ib(default=None) model_id: str | None = attr.ib(default=None) modified_at: datetime = attr.ib(factory=utcnow) name_by_user: str | None = attr.ib(default=None) name: str | None = attr.ib(default=None) - primary_config_entry: str | None = attr.ib(default=None) + # Set on devices created by splitting a pre-migration composite device: the + # identifiers and connections copied from the composite have not yet been reconciled. + # On the owning integration's first re-registration they are replaced with the ones + # it provides and this flag is cleared - a one-shot marker, unlike composite_device_id + # which is kept for the device's lifetime so old ids keep resolving; neither can be + # derived from the other. This flag and the replacement logic can be removed in HA + # Core 2027.8. + has_composite_identifiers: bool = attr.ib(default=False) serial_number: str | None = attr.ib(default=None) - # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9. + # Suggested area is deprecated and will be removed from DeviceEntry in HA Core 2026.9. _suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) + # Transient pending move target (config_entry_id, config_subentry_id) initiated by + # add_config_entry_id and completed by a subsequent remove_config_entry_id. It is + # never stored and is not part of equality. Can be removed in HA Core 2027.8. + _pending_move: _PendingMove | None = attr.ib(default=None, eq=False) + # Set only on the read-only composite device that async_get synthesizes on demand + # for a pre-migration composite device id. It holds the union of the split + # devices' config entries and subentries so callers see the pre-split device. It is + # never stored and the composite is never added to the registry. Can be removed in + # HA Core 2027.8. + _composite_subentries: dict[str, set[str | None]] | None = attr.ib( + default=None, eq=False + ) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id. + """ + if self._composite_subentries is not None: + return set(self._composite_subentries) + return {self.config_entry_id} + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry and subentry, available as config_entry_id and config_subentry_id. + """ + if self._composite_subentries is not None: + return { + entry_id: set(subentries) + for entry_id, subentries in self._composite_subentries.items() + } + return {self.config_entry_id: {self.config_subentry_id}} + + @property + def primary_config_entry(self) -> str: + """Return the primary config entry of this device. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id, which is its primary config entry. + + For a restored composite device (synthesized on the fly by async_get for a + pre-migration composite device id), this returns the composite's former + primary_config_entry, which is recorded on the split devices during migration as + composite_primary_config_entry. + """ + return self.config_entry_id + @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -407,11 +504,16 @@ class DeviceEntry: return { "area_id": self.area_id, "configuration_url": self.configuration_url, + # config_entries and config_entries_subentries are deprecated and kept for + # backwards compatibility, they can be removed in HA Core 2027.8. They use the + # compatibility properties so a restored composite reports its merged entries. "config_entries": list(self.config_entries), "config_entries_subentries": { entry_id: list(subentries) for entry_id, subentries in self.config_entries_subentries.items() }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, @@ -455,15 +557,8 @@ class DeviceEntry: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "configuration_url": self.configuration_url, "connections": list(self.connections), "created_at": self.created_at, @@ -473,12 +568,18 @@ class DeviceEntry: "id": self.id, "identifiers": list(self.identifiers), "labels": list(self.labels), + "composite_device_id": self.composite_device_id, + "composite_primary_config_entry": ( + self.composite_primary_config_entry + ), + "split_at": self.split_at, "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, "modified_at": self.modified_at, "name_by_user": self.name_by_user, "name": self.name, + "has_composite_identifiers": (self.has_composite_identifiers), "primary_config_entry": self.primary_config_entry, "serial_number": self.serial_number, "sw_version": self.sw_version, @@ -496,13 +597,32 @@ class DeviceEntry: return self._suggested_area +# async_update_device arguments that redefine which identifiers/connections a device is +# keyed by, or move it to another config entry. They are ambiguous on a synthesized +# composite (there is no single underlying device to retarget), so the composite shim +# drops them with a warning instead of fanning them out. serial_number is intentionally +# NOT here: it describes the physical device and is consistent across a composite's +# splits, so it fans out like sw_version. Can be removed in HA Core 2027.8. +_COMPOSITE_IGNORED_UPDATE_ARGS = ( + "merge_connections", + "merge_identifiers", + "new_config_entry_id", + "new_config_subentry_id", + "new_connections", + "new_identifiers", +) + + @attr.s(frozen=True, slots=True) class DeletedDeviceEntry: """Deleted Device Registry Entry.""" + # config_entry_id is None for orphaned deleted devices, i.e. devices whose owning + # config entry has been removed + config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() + area_id: str | None = attr.ib() - config_entries: set[str] = attr.ib() - config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib( validator=_normalize_connections_validator ) @@ -514,8 +634,30 @@ class DeletedDeviceEntry: modified_at: datetime = attr.ib() name_by_user: str | None = attr.ib() orphaned_timestamp: float | None = attr.ib() + # Domain of the config entry that owns (or owned) this device, recorded when the + # device is deleted so a re-added config entry only restores an orphan from the same + # integration. None for legacy stores. + domain: str | None = attr.ib(default=None) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + return {self.config_entry_id} if self.config_entry_id is not None else set() + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + if self.config_entry_id is None: + return {} + return {self.config_entry_id: {self.config_subentry_id}} + def to_device_entry( self, config_entry: ConfigEntry, @@ -537,9 +679,9 @@ class DeletedDeviceEntry: disabled_by = disabled_by if disabled_by is not UNDEFINED else None return DeviceEntry( area_id=self.area_id, + config_entry_id=config_entry.entry_id, + config_subentry_id=config_subentry_id, # type ignores: likely https://github.com/python/mypy/issues/8625 - config_entries={config_entry.entry_id}, # type: ignore[arg-type] - config_entries_subentries={config_entry.entry_id: {config_subentry_id}}, connections=self.connections & connections, # type: ignore[arg-type] created_at=self.created_at, disabled_by=disabled_by, @@ -556,15 +698,8 @@ class DeletedDeviceEntry: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at, "disabled_by": self.disabled_by @@ -577,11 +712,23 @@ class DeletedDeviceEntry: "modified_at": self.modified_at, "name_by_user": self.name_by_user, "orphaned_timestamp": self.orphaned_timestamp, + "domain": self.domain, } ) ) +def _copy_if_exists(source: str, destination: str) -> bool: + """Copy source to destination when source exists (runs in the executor). + + Returns whether the file was copied. + """ + if not os.path.isfile(source): + return False + shutil.copyfile(source, destination) + return True + + class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" @@ -593,10 +740,12 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" - # Support for a future major version bump to 2 added in HA Core 2025.2. - # Major versions 1 and 2 will be the same, except that version 2 will no - # longer store a list of config_entries. + # Note: There's no version 2, it was planned and supported by previous versions + # of the migrator which treated version 2 like version 1. if old_major_version < 3: + # Copy the store before the version 3 migrator rewrites every device, so a + # user can recover the pre-migration registry if the migration misbehaves. + await self._async_backup_store() if old_minor_version < 2: # Version 1.2 implements migration and freezes the available keys, # populate keys which were introduced before version 1.2 @@ -677,80 +826,277 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # of version 1.10 for device in old_data["deleted_devices"]: device["disabled_by_undefined"] = old_minor_version < 10 + # Version 3 restricts a device to a single config entry and subentry, + # introduced in 2026.8. Composite devices which belonged to several + # config entries (or several subentries of one entry) are split into one + # device per (config entry, subentry). Each split device keeps a copy of + # the identifiers and connections and a reference (composite_device_id) to the original + # composite device id, so that actions targeting the old id still reach + # all split devices. Entities are moved to the matching split device when + # the registries are loaded. + migrated_at = utcnow().isoformat() + devices: list[dict[str, Any]] = [] + # Ids of active devices dropped for lacking a config entry; a retained + # child's via_device_id pointing at one is detached below. + dropped_device_ids: set[str] = set() + # old composite id -> {config entry id -> new split id}, to rewrite + # via_device_id links pointing at a split parent + composite_splits: dict[str, dict[str, str]] = {} + # Active splits whose copied disabled_by must be reconciled against their + # single config entry once the config entries are loaded + migrated_active_splits: list[dict[str, Any]] = [] + for device in old_data["devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if not pairs: + # Drop devices that have no config entry / subentry pairs + dropped_device_ids.add(device["id"]) + continue + if len(pairs) == 1: + config_entry_id, subentry_id = pairs[0] + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["composite_device_id"] = None + device["composite_primary_config_entry"] = None + device["split_at"] = None + device["has_composite_identifiers"] = False + devices.append(device) + continue + old_id = device["id"] + composite_primary = device.get("primary_config_entry") + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["primary_config_entry"] = config_entry_id + split["composite_device_id"] = old_id + split["composite_primary_config_entry"] = composite_primary + split["split_at"] = migrated_at + split["has_composite_identifiers"] = True + devices.append(split) + migrated_active_splits.append(split) + composite_splits.setdefault(old_id, {})[config_entry_id] = split[ + "id" + ] + # Rewrite via_device_id links that pointed at a now-split composite parent + # to a live split: the parent's split in the child's own config entry when + # there is one, otherwise any of the parent's splits, so the link never + # dangles on the removed composite id. A link to a retained unsplit parent is + # left unchanged; a link to a dropped parent is detached below. + for device in devices: + if ( + splits := composite_splits.get(device["via_device_id"]) + ) is not None: + device["via_device_id"] = splits.get( + device["config_entry_id"], next(iter(splits.values())) + ) + elif device["via_device_id"] in dropped_device_ids: + # The parent was dropped (no config entries); detach the link as + # async_remove_device would, so it does not dangle on a removed id. + device["via_device_id"] = None + old_data["devices"] = devices + # A split inherited the composite's disabled_by, which may not match its + # single config entry (e.g. a split owned by an enabled entry must not stay + # CONFIG_ENTRY disabled). Config entries load concurrently, so wait for them + # and reconcile each split against its own entry. + if migrated_active_splits: + await self.hass.config_entries.async_wait_initialized() + for split in migrated_active_splits: + config_entry = self.hass.config_entries.async_get_entry( + split["config_entry_id"] + ) + if config_entry is not None: + _migrate_device_disabled_by( + split, config_entry.disabled_by is not None + ) + deleted_devices: list[dict[str, Any]] = [] + for device in old_data["deleted_devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if len(pairs) <= 1: + # Unlike active devices, config_entry_id=None is a valid + # (orphaned) state for a deleted device, so a deleted device with + # no config entries is kept rather than dropped. + config_entry_id, subentry_id = pairs[0] if pairs else (None, None) + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["domain"] = None + deleted_devices.append(device) + continue + # A deleted device that belonged to several config entries or subentries + # is split like an active one - each split keeps a copy of the + # identifiers/connections so every config entry can still restore its + # share when a matching device is re-registered. + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["domain"] = None + deleted_devices.append(split) + old_data["deleted_devices"] = deleted_devices + # config_entries and config_entries_subentries are deprecated; v3 stores only + # the singular config_entry_id / config_subentry_id (single-entry devices kept + # the old keys, splits copied them via deepcopy). + for migrated in (*devices, *deleted_devices): + migrated.pop("config_entries", None) + migrated.pop("config_entries_subentries", None) - if old_major_version > 2: + if old_major_version > 3: raise NotImplementedError return old_data + async def _async_backup_store(self) -> None: + """Copy the store file to a timestamped backup before migrating.""" + source = self.path + backup = f"{source}.{utcnow().strftime('%Y%m%d_%H%M%S')}.migration_backup" + try: + copied = await self.hass.async_add_executor_job( + _copy_if_exists, source, backup + ) + except OSError as err: + _LOGGER.warning("Could not back up %s before migration: %s", source, err) + else: + if copied: + _LOGGER.info("Backed up %s to %s before migration", source, backup) + class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( BaseRegistryItems[_EntryTypeT] ): """Container for device registry items, maps device id -> entry. - Maintains two additional indexes: - - (connection_type, connection identifier) -> entry - - (DOMAIN, identifier) -> entry + Maintains two additional indexes. An identifier or connection can be shared by + several devices, each belonging to a different config entry, so each index maps a + connection or identifier to the devices that have it, keyed by config entry id: + - (connection_type, connection identifier) -> {config_entry_id: entry} + - (DOMAIN, identifier) -> {config_entry_id: entry} """ def __init__(self) -> None: """Initialize the container.""" super().__init__() - self._connections: dict[tuple[str, str], _EntryTypeT] = {} - self._identifiers: dict[tuple[str, str], _EntryTypeT] = {} + self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} + self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} @override def _index_entry(self, key: str, entry: _EntryTypeT) -> None: """Index an entry.""" + config_entry_id = entry.config_entry_id for connection in entry.connections: - self._connections[connection] = entry + self._connections.setdefault(connection, {})[config_entry_id] = entry for identifier in entry.identifiers: - self._identifiers[identifier] = entry + self._identifiers.setdefault(identifier, {})[config_entry_id] = entry @override def _unindex_entry( self, key: str, replacement_entry: _EntryTypeT | None = None ) -> None: - """Unindex an entry.""" + """Unindex an entry. + + Guards against collisions, the code below can be simplified once + collisions are not longer allowed, refer to commit history in PR + 175785. + """ old_entry = self.data[key] + config_entry_id = old_entry.config_entry_id for connection in old_entry.connections: - if connection in self._connections: - del self._connections[connection] + by_config_entry = self._connections.get(connection) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._connections[connection] for identifier in old_entry.identifiers: - if identifier in self._identifiers: - del self._identifiers[identifier] + by_config_entry = self._identifiers.get(identifier) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._identifiers[identifier] def get_entry( self, identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, + *, + config_entry_id: str | None | UndefinedType = UNDEFINED, ) -> _EntryTypeT | None: - """Get entry from identifiers or connections.""" + """Get the first entry matching identifiers or connections. + + If config_entry_id is given, only an entry belonging to that config entry is + returned. Otherwise the first matching entry from any config entry is returned. + """ if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - return self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] if not connections: return None for connection in _normalize_connections(connections): - if connection in self._connections: - return self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] return None def get_entries( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, - ) -> Iterable[_EntryTypeT]: - """Get entries from identifiers or connections.""" + identifiers: AbstractSet[tuple[str, str]] | None = None, + connections: AbstractSet[tuple[str, str]] | None = None, + ) -> list[_EntryTypeT]: + """Get all entries matching identifiers or connections, across config entries.""" + entries: dict[str, _EntryTypeT] = {} if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - yield self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry if connections: for connection in _normalize_connections(connections): - if connection in self._connections: - yield self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry + return list(entries.values()) class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): @@ -759,16 +1105,18 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): def __init__(self) -> None: """Initialize the container. - Maintains three additional indexes: + Maintains four additional indexes: - area_id -> dict[key, True] - config_entry_id -> dict[key, True] - label -> dict[key, True] + - composite_device_id -> dict[key, True] """ super().__init__() self._area_id_index: RegistryIndexType = defaultdict(dict) self._config_entry_id_index: RegistryIndexType = defaultdict(dict) self._labels_index: RegistryIndexType = defaultdict(dict) + self._composite_device_id_index: RegistryIndexType = defaultdict(dict) @override def _index_entry(self, key: str, entry: DeviceEntry) -> None: @@ -778,8 +1126,9 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): self._area_id_index[area_id][key] = True for label in entry.labels: self._labels_index[label][key] = True - for config_entry_id in entry.config_entries: - self._config_entry_id_index[config_entry_id][key] = True + self._config_entry_id_index[entry.config_entry_id][key] = True + if entry.composite_device_id is not None: + self._composite_device_id_index[entry.composite_device_id][key] = True @override def _unindex_entry( @@ -792,8 +1141,13 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) - for config_entry_id in entry.config_entries: - self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index) + self._unindex_entry_value( + key, entry.config_entry_id, self._config_entry_id_index + ) + if entry.composite_device_id is not None: + self._unindex_entry_value( + key, entry.composite_device_id, self._composite_device_id_index + ) super()._unindex_entry(key, replacement_entry) def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]: @@ -815,12 +1169,98 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): data[key] for key in self._config_entry_id_index.get(config_entry_id, ()) ] + def get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Get the devices a pre-migration composite device was split into.""" + data = self.data + return [ + data[key] + for key in self._composite_device_id_index.get(composite_device_id, ()) + ] + + +class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): + """Container for deleted device registry entries. + + A deleted device that still belongs to a config entry is indexed by config entry id in + the base class, like an active device. An orphaned deleted device (its config entry + removed) has no config entry id and would collide with every other orphan in the base + config_entry_id=None slot, so orphans are kept out of the base index and tracked in a + separate index keyed by device id, which is unique so orphans never shadow each other. + Orphans are matched on restore by get_orphaned_entry. + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._orphaned_connections: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + self._orphaned_identifiers: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + + @override + def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None: + """Index an entry, keeping orphans in the separate id-keyed index.""" + if entry.config_entry_id is not None: + super()._index_entry(key, entry) + return + for connection in entry.connections: + self._orphaned_connections.setdefault(connection, {})[entry.id] = entry + for identifier in entry.identifiers: + self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry + + @override + def _unindex_entry( + self, key: str, replacement_entry: DeletedDeviceEntry | None = None + ) -> None: + """Unindex an entry from the base or the orphan index.""" + entry = self.data[key] + if entry.config_entry_id is not None: + super()._unindex_entry(key, replacement_entry) + return + for connection in entry.connections: + if connection in self._orphaned_connections: + del self._orphaned_connections[connection][entry.id] + if not self._orphaned_connections[connection]: + del self._orphaned_connections[connection] + for identifier in entry.identifiers: + if identifier in self._orphaned_identifiers: + del self._orphaned_identifiers[identifier][entry.id] + if not self._orphaned_identifiers[identifier]: + del self._orphaned_identifiers[identifier] + + def get_orphaned_entry( + self, + identifiers: set[tuple[str, str]] | None, + connections: set[tuple[str, str]] | None, + domain: str, + ) -> DeletedDeviceEntry | None: + """Return an orphan of the given domain to restore. + + Orphans are matched on their recorded domain so a chance identifier or connection + collision doesn't restore another integration's device. A domain-less orphan + (carried over by the migration with no recoverable domain) is left for the + periodic purge rather than restored. + """ + orphans: dict[str, DeletedDeviceEntry] = {} + for identifier in identifiers or (): + orphans.update(self._orphaned_identifiers.get(identifier, {})) + for connection in _normalize_connections(connections or set()): + orphans.update(self._orphaned_connections.get(connection, {})) + for entry in orphans.values(): + if entry.domain == domain: + return entry + return None + class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" devices: ActiveDeviceRegistryItems - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] + deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -842,8 +1282,53 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): We retrieve the DeviceEntry from the underlying dict to avoid the overhead of the UserDict __getitem__. + + For a pre-migration composite device id, a read-only composite device + merged from the split devices is returned, so integration code that resolves a + device by id (e.g. in a service handler) keeps working. The composite is + synthesized on demand and never stored, so it stays invisible to enumeration, + identifier search and the frontend device list. """ - return self._device_data.get(device_id) + if (device := self._device_data.get(device_id)) is not None: + return device + if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + return self._restore_composite_device(device_id, split_devices) + return None + + @callback + def _restore_composite_device( + self, device_id: str, split_devices: list[DeviceEntry] + ) -> DeviceEntry: + """Synthesize a read-only composite device from its split devices.""" + composite_subentries: dict[str, set[str | None]] = {} + identifiers: set[tuple[str, str]] = set() + connections: set[tuple[str, str]] = set() + for split_device in split_devices: + composite_subentries.setdefault(split_device.config_entry_id, set()).add( + split_device.config_subentry_id + ) + identifiers |= split_device.identifiers + connections |= split_device.connections + # Functional identity (identifiers, connections, serial_number) is consistent + # across splits of the same physical device. Use the split owning the composite's + # former primary config entry as the base, so config_entry_id - and thus + # primary_config_entry - reports the composite's former primary. + primary_config_entry = split_devices[0].composite_primary_config_entry + base = next( + ( + split_device + for split_device in split_devices + if split_device.config_entry_id == primary_config_entry + ), + split_devices[0], + ) + return attr.evolve( + base, + composite_subentries=composite_subentries, + connections=connections, # type: ignore[arg-type] + id=device_id, + identifiers=identifiers, # type: ignore[arg-type] + ) @callback def async_get_device( @@ -851,8 +1336,100 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, ) -> DeviceEntry | None: - """Check if device is registered.""" - return self.devices.get_entry(identifiers, connections) + """Check if a device is registered. + + Identifiers and connections are unique per config entry. If several config + entries share the looked-up identifier or connection, the match is resolved to a + single device when possible - preferring the device whose config entry domain + matches the looked-up identifier. If the remaining matches are the splits of one + pre-migration composite device, a read-only composite spanning them is returned + (async_update_device and async_remove_device fan it out to the underlying + devices). Otherwise, for independent devices sharing an identifier or connection, + one owned by the calling integration is preferred, falling back to the first + match. + """ + matches = self._async_matching_devices(identifiers, connections) + if len(matches) <= 1: + return matches[0] if matches else None + # If the matches are the splits of one pre-migration composite device, return a + # read-only composite over them, reusing the composite's id so stored references + # (an automation, a fired event, or an entity holding the old device id) keep + # resolving to it as before the split. + composite_device_ids = {match.composite_device_id for match in matches} + if ( + len(composite_device_ids) == 1 + and (pre_migration_id := next(iter(composite_device_ids))) is not None + ): + return self._restore_composite_device(pre_migration_id, matches) + # Otherwise they are independent devices sharing an identifier or connection. + # Prefer one owned by the calling integration so the caller resolves to its own + # device rather than an insertion-order-dependent one; fall back to the first. + if (domain := _current_integration_domain()) is not None and ( + device := self._first_device_in_domain(matches, domain) + ) is not None: + return device + return matches[0] + + def _first_device_in_domain( + self, devices: Iterable[DeviceEntry], domain: str + ) -> DeviceEntry | None: + """Return the first device whose config entry belongs to domain.""" + for device in devices: + entry = self.hass.config_entries.async_get_entry(device.config_entry_id) + if entry is not None and entry.domain == domain: + return device + return None + + @callback + def _async_matching_devices( + self, + identifiers: AbstractSet[tuple[str, str]] | None, + 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) + if len(matches) > 1 and identifiers: + domains = {identifier[0] for identifier in identifiers} + preferred = [ + device + for device in matches + if ( + entry := self.hass.config_entries.async_get_entry( + device.config_entry_id + ) + ) + and entry.domain in domains + ] + if preferred: + return preferred + return matches + + @callback + def _async_device_ids_for_composite_device_id( + 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: + return None + 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 + + @callback + def async_get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Return the devices a composite device id represents. + + A composite device id is a pre-migration composite id - a device that belonged to + several config entries, split into one device per config entry, each keeping the + original id as composite_device_id. The underlying live devices are returned so + that actions and entity lookups targeting the composite id still reach all of + them; unmodified integrations keep the pre-rewrite behaviour, where a shared + 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) def _substitute_name_placeholders( self, @@ -908,7 +1485,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): sw_version: str | None | UndefinedType = 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] | None | UndefinedType = UNDEFINED, + via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" default_manufacturer = _validate_str( @@ -931,6 +1511,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): f"Can't link device to unknown config entry {config_entry_id}" ) + # Validate before mutating the registry below. `via_device=None` (an explicit + # "no via device") alongside a via_device_id is contradictory, so reject it too. + if via_device is not UNDEFINED and via_device_id is not UNDEFINED: + raise HomeAssistantError( + "Passing both `via_device` and `via_device_id` is not allowed; " + "`via_device` is deprecated, pass `via_device_id` only" + ) + if ( + config_subentry_id is not UNDEFINED + and config_subentry_id is not None + and config_subentry_id not in config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {config_entry_id} has no subentry {config_subentry_id}" + ) + if translation_key: full_translation_key = ( f"component.{config_entry.domain}.device.{translation_key}.name" @@ -958,6 +1554,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("name", name), ("suggested_area", suggested_area), ("via_device", via_device), + ("via_device_id", via_device_id), *validated_fields.items(), ) if val is not UNDEFINED @@ -974,7 +1571,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): connections = _normalize_connections(connections) device = self.devices.get_entry( - identifiers=identifiers, connections=connections + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, ) is_new = False @@ -982,7 +1581,20 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is None: is_new = True - deleted_device = self.deleted_devices.get_entry(identifiers, connections) + deleted_device = self.deleted_devices.get_entry( + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, + ) + if deleted_device is None: + # Fall back to an orphan (its owning config entry was removed) + # so re-adding an integration restores the device id, area, labels and name + # 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( + identifiers, connections, config_entry.domain + ) if deleted_device is None: area_id: str | None = None if ( @@ -995,7 +1607,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id - device = DeviceEntry(area_id=area_id) + device = DeviceEntry( + area_id=area_id, + config_entry_id=config_entry_id, + # Interpret not specifying a subentry as None + config_subentry_id=( + config_subentry_id + if config_subentry_id is not UNDEFINED + else None + ), + ) else: self.deleted_devices.pop(deleted_device.id) @@ -1024,7 +1645,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.devices.get_entry(identifiers={via_device})) is None: + # Resolve the deprecated via_device to a device id. The identifier is not + # unique across config entries, so prefer a via device in the same config + # entry, then one from the same integration (domain), falling back to 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( + identifiers={via_device}, config_entry_id=config_entry_id + ) + or self._first_device_in_domain( + self.devices.get_entries(identifiers={via_device}), + config_entry.domain, + ) + or self.devices.get_entry(identifiers={via_device}) + ) + if via is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1032,25 +1668,46 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): core_behavior=ReportBehavior.LOG, breaks_in_ha_version="2025.12.0", ) + via_device_id = via.id if via else UNDEFINED + elif via_device is None: + # An explicit `via_device=None` means "no via device" (a via_device_id + # alongside it is rejected above). + via_device_id = None - via_device_id: str | UndefinedType = via.id if via else UNDEFINED + # On the owning integration's first re-registration of a device created by + # splitting a pre-migration composite device, replace the identifiers and + # connections copied from the composite with the ones the integration provides, + # instead of merging. This block and the has_composite_identifiers flag + # can be removed in HA Core 2027.8. + identifiers_connections: dict[str, Any] + has_composite_identifiers: bool | UndefinedType = UNDEFINED + if not is_new and device.has_composite_identifiers: + identifiers_connections = { + "new_connections": connections, + "new_identifiers": identifiers, + } + has_composite_identifiers = False else: - via_device_id = UNDEFINED + identifiers_connections = { + "merge_connections": connections or UNDEFINED, + "merge_identifiers": identifiers or UNDEFINED, + } device = self._async_update_device( device.id, allow_collisions=True, - add_config_entry_id=config_entry_id, - add_config_subentry_id=config_subentry_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, is_new=is_new, - merge_connections=connections or UNDEFINED, - merge_identifiers=identifiers or UNDEFINED, name=name, + has_composite_identifiers=has_composite_identifiers, + # Move the device if the integration re-registers it under a different + # subentry; UNDEFINED leaves the subentry unchanged. Also validates an + # explicitly provided subentry for new devices. + new_config_subentry_id=config_subentry_id, suggested_area=suggested_area, via_device_id=via_device_id, + **identifiers_connections, **validated_fields, ) @@ -1071,7 +1728,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): allow_collisions: bool = False, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, - device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -1084,6 +1740,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + # has_composite_identifiers can be removed in HA Core 2027.8 + has_composite_identifiers: bool | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | None | UndefinedType = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, @@ -1106,9 +1766,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs - config_entries = old.config_entries - config_entries_subentries = old.config_entries_subentries - if add_config_entry_id is not UNDEFINED: if ( add_config_entry := self.hass.config_entries.async_get_entry( @@ -1143,6 +1800,26 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Can't remove config subentry without specifying config entry" ) + if ( + new_config_entry_id is not UNDEFINED + and self.hass.config_entries.async_get_entry(new_config_entry_id) is None + ): + raise HomeAssistantError( + f"Can't move device to unknown config entry {new_config_entry_id}" + ) + + if ( + new_config_entry_id is not UNDEFINED + or new_config_subentry_id is not UNDEFINED + ) and ( + add_config_entry_id is not UNDEFINED + or remove_config_entry_id is not UNDEFINED + ): + raise HomeAssistantError( + "Can't combine new_config_entry_id or new_config_subentry_id with " + "add_config_entry_id or remove_config_entry_id" + ) + if not new_connections and not new_identifiers: raise HomeAssistantError( "A device must have at least one of identifiers or connections" @@ -1158,109 +1835,133 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Cannot define both merge_identifiers and new_identifiers" ) - if add_config_entry_id is not UNDEFINED: - if add_config_subentry_id is UNDEFINED: - # Interpret not specifying a subentry as None (the main entry) - add_config_subentry_id = None - - primary_entry_id = old.primary_config_entry - if ( - device_info_type == "primary" - and add_config_entry_id != primary_entry_id - ): - if ( - primary_entry_id is None - or not ( - primary_entry := self.hass.config_entries.async_get_entry( - primary_entry_id - ) + # A device belongs to exactly one config entry and subentry: + # - add_config_entry_id (with an optional add_config_subentry_id) records a + # transient pending move to that config entry and subentry; on its own it does + # not move the device. Integrations move a device by adding the new config + # entry and then removing the current one, often in separate calls; the removal + # of the current config entry performs the pending move. + # - remove_config_entry_id on the owning entry performs a pending move if there + # is one, otherwise it removes the device, since it has no other config entry. + # - new_config_entry_id / new_config_subentry_id move the device immediately. + target_config_entry_id: str | UndefinedType = UNDEFINED + target_config_subentry_id: str | None | UndefinedType = UNDEFINED + pending_move: _PendingMove | None | UndefinedType = UNDEFINED + if new_config_entry_id is not UNDEFINED: + target_config_entry_id = new_config_entry_id + target_config_subentry_id = ( + new_config_subentry_id + if new_config_subentry_id is not UNDEFINED + else None + ) + # An immediate move to a new config entry supersedes a deferred move from an + # earlier add_config_entry_id; clear it so a later removal of the new owner + # deletes the device instead of performing the stale move. + pending_move = None + elif new_config_subentry_id is not UNDEFINED: + target_config_subentry_id = new_config_subentry_id + else: + if add_config_entry_id is not UNDEFINED: + # Adding the config entry (and subentry) the device already belongs to is a + # no-op; recording it as a pending move would make a later removal of that + # sole owner move the device to itself instead of deleting it. + already_owner = add_config_entry_id == old.config_entry_id and ( + add_config_subentry_id is UNDEFINED + or add_config_subentry_id == old.config_subentry_id + ) + if not already_owner: + pending_move = _PendingMove( + add_config_entry_id, + add_config_subentry_id + if add_config_subentry_id is not UNDEFINED + else None, + _current_integration_domain(), ) - or primary_entry.domain in LOW_PRIO_CONFIG_ENTRY_DOMAINS - ): - new_values["primary_config_entry"] = add_config_entry_id - old_values["primary_config_entry"] = primary_entry_id - - if add_config_entry_id not in old.config_entries: - config_entries = old.config_entries | {add_config_entry_id} - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: {add_config_subentry_id} - } - # Enable the device if it was disabled by config entry and we're adding - # a non disabled config entry + if remove_config_entry_id == old.config_entry_id and ( + remove_config_subentry_id is UNDEFINED + or remove_config_subentry_id == old.config_subentry_id + ): + move_from_prior_call = pending_move is UNDEFINED + move_target = ( + pending_move if pending_move is not UNDEFINED else old._pending_move # noqa: SLF001 + ) + # A deferred move armed by an earlier add_config_entry_id only completes + # if the integration now removing the owning entry is the one that armed + # it. A removal from a different integration (e.g. device_tracker + # attaching a shared MAC) is unrelated, so cancel the move and delete the + # device instead of silently transferring it. Origins from core/tests are + # undetermined (None) and never cancel. if ( - # mypy says add_config_entry can be None. - # That's impossible, because we raise above if - # that happens - not add_config_entry.disabled_by # type: ignore[union-attr] - and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY + move_target is not None + and move_from_prior_call + and move_target.origin_domain is not None + and (current_domain := _current_integration_domain()) is not None + and current_domain != move_target.origin_domain ): - new_values["disabled_by"] = None - old_values["disabled_by"] = old.disabled_by - elif ( - add_config_subentry_id - not in old.config_entries_subentries[add_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: old.config_entries_subentries[ - add_config_entry_id - ] - | {add_config_subentry_id} - } - - if ( - remove_config_entry_id is not UNDEFINED - and remove_config_entry_id in config_entries - ): - if remove_config_subentry_id is UNDEFINED: - config_entries_subentries = dict(old.config_entries_subentries) - del config_entries_subentries[remove_config_entry_id] - elif ( - remove_config_subentry_id - in old.config_entries_subentries[remove_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - remove_config_entry_id: old.config_entries_subentries[ - remove_config_entry_id - ] - - {remove_config_subentry_id} - } - if not config_entries_subentries[remove_config_entry_id]: - del config_entries_subentries[remove_config_entry_id] - - if remove_config_entry_id not in config_entries_subentries: - if config_entries == {remove_config_entry_id}: + move_target = None + if move_target is None: self.async_remove_device(device_id) return None + target_config_entry_id = move_target.config_entry_id + target_config_subentry_id = move_target.config_subentry_id + pending_move = None + # A pre-migration composite's splits share identity, so once one split + # 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( + old.composite_device_id + ): + if ( + sibling.id != device_id + and sibling._pending_move is not None # noqa: SLF001 + ): + self.devices[sibling.id] = attr.evolve( + sibling, pending_move=None + ) - if remove_config_entry_id == old.primary_config_entry: - new_values["primary_config_entry"] = None - old_values["primary_config_entry"] = old.primary_config_entry - - config_entries = config_entries - {remove_config_entry_id} - - # Disable the device if it is enabled and all remaining config entries - # are disabled - has_enabled_config_entries = any( - config_entry.disabled_by is None - for config_entry_id in config_entries - if ( - config_entry := self.hass.config_entries.async_get_entry( - config_entry_id - ) - ) - is not None + if target_config_subentry_id not in (UNDEFINED, None): + resolved_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) + resolved_config_entry = self.hass.config_entries.async_get_entry( + resolved_config_entry_id + ) + if ( + resolved_config_entry is None + or target_config_subentry_id not in resolved_config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {resolved_config_entry_id} has no" + f" subentry {target_config_subentry_id}" ) - if not has_enabled_config_entries and old.disabled_by is None: - new_values["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY - old_values["disabled_by"] = old.disabled_by - if config_entries != old.config_entries: - new_values["config_entries"] = config_entries - old_values["config_entries"] = old.config_entries + if ( + target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + ): + new_values["config_entry_id"] = target_config_entry_id + old_values["config_entry_id"] = old.config_entry_id + if ( + target_config_subentry_id is not UNDEFINED + and target_config_subentry_id != old.config_subentry_id + ): + new_values["config_subentry_id"] = target_config_subentry_id + old_values["config_subentry_id"] = old.config_subentry_id + # pending_move is a transient runtime-only attribute; it is not reported in the + # update event (not added to old_values) and never stored + if pending_move is not UNDEFINED and pending_move != old._pending_move: # noqa: SLF001 + new_values["pending_move"] = pending_move - if config_entries_subentries != old.config_entries_subentries: - new_values["config_entries_subentries"] = config_entries_subentries - old_values["config_entries_subentries"] = old.config_entries_subentries + # Identifiers and connections are unique per config entry, so when the device is + # moved to another config entry they are validated against the new one + effective_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1268,6 +1969,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, + effective_config_entry_id, merge_connections, allow_collisions, ) @@ -1279,7 +1981,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if merge_identifiers is not UNDEFINED: merge_identifiers = self._validate_identifiers( - device_id, merge_identifiers, allow_collisions + device_id, + effective_config_entry_id, + merge_identifiers, + allow_collisions, ) old_identifiers = old.identifiers if not merge_identifiers.issubset(old_identifiers): @@ -1289,16 +1994,52 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if new_connections is not UNDEFINED: added_connections = new_values["connections"] = self._validate_connections( - device_id, new_connections, False + device_id, effective_config_entry_id, new_connections, False ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: added_identifiers = new_values["identifiers"] = self._validate_identifiers( - device_id, new_identifiers, False + device_id, effective_config_entry_id, new_identifiers, False ) old_values["identifiers"] = old.identifiers + # On a move to another config entry, validate the identifiers and connections + # retained from the old entry against the new one, so the move can't silently + # overwrite the index slot of a device that already has the same identity there. + # A full new_identifiers / new_connections replacement is validated above; + # merge_* only adds, so the retained old values still need checking here. + if effective_config_entry_id != old.config_entry_id: + if new_identifiers is UNDEFINED: + self._validate_identifiers( + device_id, effective_config_entry_id, old.identifiers, False + ) + if new_connections is UNDEFINED: + self._validate_connections( + device_id, effective_config_entry_id, old.connections, False + ) + + # On a move, reflect the new owning config entry's disabled state (as restoring a + # deleted device does) unless disabled_by was passed explicitly: disable an + # enabled device moved onto a disabled entry, and clear a CONFIG_ENTRY disable + # when moved onto an enabled entry. A USER disable is preserved. + if ( + disabled_by is UNDEFINED + and target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + and ( + target_entry := self.hass.config_entries.async_get_entry( + target_config_entry_id + ) + ) + is not None + ): + if target_entry.disabled_by: + if old.disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + for attr_name, value in ( ("area_id", area_id), ("configuration_url", configuration_url), @@ -1311,6 +2052,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("model_id", model_id), ("name", name), ("name_by_user", name_by_user), + ("has_composite_identifiers", has_composite_identifiers), ("serial_number", serial_number), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -1336,13 +2078,28 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new = attr.evolve(old, **new_values) self.devices[device_id] = new - # NOTE: Once we solve the broader issue of duplicated devices, we might - # want to revisit it. Instead of simply removing the duplicated deleted device, - # we might want to merge the information from it into the non-deleted device. + # 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- + # identity move, so match the target entry's deleted device by the full identity. + match_identifiers: set[tuple[str, str]] | None + match_connections: set[tuple[str, str]] | None + if effective_config_entry_id != old.config_entry_id: + match_identifiers = new.identifiers + match_connections = new.connections + else: + match_identifiers = added_identifiers + match_connections = added_connections for deleted_device in self.deleted_devices.get_entries( - added_identifiers, added_connections + match_identifiers, match_connections ): - del self.deleted_devices[deleted_device.id] + # get_entries matches across config entries, but identifiers/connections are + # unique per config entry - only remove the deleted device owned by this + # device's config entry, so another entry can still restore its own. + if ( + deleted_device.config_entry_id == effective_config_entry_id + and deleted_device.id in self.deleted_devices + ): + 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 @@ -1374,7 +2131,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): add_config_subentry_id: str | None | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, - device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -1386,6 +2142,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | None | UndefinedType = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, @@ -1398,11 +2156,57 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) -> DeviceEntry | None: """Update device attributes. - :param add_config_subentry_id: Add the device to a specific - subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a - specific subentry of remove_config_entry_id + A device belongs to a single config entry and subentry. To move a device to + another config entry or subentry, pass new_config_entry_id and/or + new_config_subentry_id. To remove a device, pass remove_config_entry_id with the + device's config entry. + + :param add_config_entry_id: Deprecated. Combined with remove_config_entry_id it + moves the device; on its own it does nothing. + :param add_config_subentry_id: Deprecated. Combined with remove_config_subentry_id + it moves the device to another subentry; on its own it does nothing. + :param new_config_entry_id: Move the device to this config entry. + :param new_config_subentry_id: Move the device to this subentry. + :param remove_config_entry_id: Remove the device if it is the device's config + entry, unless combined with add_config_entry_id to move the device. + :param remove_config_subentry_id: Remove the device from a specific subentry of + remove_config_entry_id. """ + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + # Fan the update out to each underlying device; keep in sync with the + # update parameters above. + update_args = { + "add_config_entry_id": add_config_entry_id, + "add_config_subentry_id": add_config_subentry_id, + "area_id": area_id, + "configuration_url": configuration_url, + "disabled_by": disabled_by, + "entry_type": entry_type, + "hw_version": hw_version, + "labels": labels, + "manufacturer": manufacturer, + "merge_connections": merge_connections, + "merge_identifiers": merge_identifiers, + "model": model, + "model_id": model_id, + "name_by_user": name_by_user, + "name": name, + "new_config_entry_id": new_config_entry_id, + "new_config_subentry_id": new_config_subentry_id, + "new_connections": new_connections, + "new_identifiers": new_identifiers, + "remove_config_entry_id": remove_config_entry_id, + "remove_config_subentry_id": remove_config_subentry_id, + "serial_number": serial_number, + "suggested_area": suggested_area, + "sw_version": sw_version, + "via_device_id": via_device_id, + } + return self._async_update_composite_device( + device_id, underlying_ids, update_args + ) if suggested_area is not UNDEFINED: report_usage( "passes a suggested_area to device_registry.async_update device", @@ -1425,7 +2229,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): add_config_entry_id=add_config_entry_id, add_config_subentry_id=add_config_subentry_id, area_id=area_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, labels=labels, @@ -1433,6 +2236,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): merge_identifiers=merge_identifiers, name_by_user=name_by_user, name=name, + new_config_entry_id=new_config_entry_id, + new_config_subentry_id=new_config_subentry_id, new_connections=new_connections, new_identifiers=new_identifiers, remove_config_entry_id=remove_config_entry_id, @@ -1446,10 +2251,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _validate_connections( self, device_id: str, + config_entry_id: str, connections: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Normalize and validate connections, raise on collision with other devices.""" + """Normalize and validate connections, raise on collision with other devices. + + Connections are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ normalized_connections = _normalize_connections(connections) if allow_collisions: return normalized_connections @@ -1459,7 +2269,9 @@ 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(connections={connection}) + existing_device := self.devices.get_entry( + connections={connection}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1471,10 +2283,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _validate_identifiers( self, device_id: str, + config_entry_id: str, identifiers: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Validate identifiers, raise on collision with other devices.""" + """Validate identifiers, raise on collision with other devices. + + Identifiers are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ if allow_collisions: return identifiers @@ -1483,21 +2300,70 @@ 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(identifiers={identifier}) + 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) return identifiers + @callback + def _async_update_composite_device( + self, + composite_id: str, + underlying_ids: list[str], + update_args: dict[str, Any], + ) -> DeviceEntry | None: + """Fan an async_update_device call on a composite out to its real devices.""" + forward = { + name: value for name, value in update_args.items() if value is not UNDEFINED + } + if ignored := [ + name for name in _COMPOSITE_IGNORED_UPDATE_ARGS if name in forward + ]: + # These rewrite a device's functional identity or move it, which is ambiguous + # across the composite's underlying devices; drop them rather than corrupt or + # collide, and report the offending integration. + report_usage( + f"passed {', '.join(ignored)} to device_registry.async_update_device " + "for a composite device that spans several config entries (returned for " + "an ambiguous async_get_device lookup, or " + "resolved from a stored device id of a pre-migration composite); the " + "argument cannot be applied to the merged device and was ignored - " + "target a single device, e.g. one returned by " + "async_entries_for_config_entry", + core_behavior=ReportBehavior.LOG, + ) + for name in ignored: + del forward[name] + for underlying_id in underlying_ids: + self.async_update_device(underlying_id, **forward) + remaining = [ + self.devices[underlying_id] + for underlying_id in underlying_ids + if underlying_id in self.devices + ] + if not remaining: + return None + return self._restore_composite_device(composite_id, remaining) + @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + for underlying_id in underlying_ids: + self.async_remove_device(underlying_id) + return self.hass.verify_event_loop_thread("device_registry.async_remove_device") 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( area_id=device.area_id, - config_entries=device.config_entries, - config_entries_subentries=device.config_entries_subentries, + config_entry_id=device.config_entry_id, + config_subentry_id=device.config_subentry_id, connections=device.connections, created_at=device.created_at, disabled_by=device.disabled_by, @@ -1507,6 +2373,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): modified_at=utcnow(), name_by_user=device.name_by_user, orphaned_timestamp=None, + domain=config_entry.domain if config_entry is not None else None, ) for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: @@ -1530,19 +2397,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): data = await self._store.async_load() devices = ActiveDeviceRegistryItems() - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] = DeviceRegistryItems() + deleted_devices = DeletedDeviceRegistryItems() if data is not None: for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries_subentries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -1567,13 +2429,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for iden in device["identifiers"] }, labels=set(device["labels"]), + composite_device_id=device["composite_device_id"], + composite_primary_config_entry=device[ + "composite_primary_config_entry" + ], + split_at=( + datetime.fromisoformat(device["split_at"]) + if device["split_at"] + else None + ), manufacturer=device["manufacturer"], model=device["model"], model_id=device["model_id"], modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], name=device["name"], - primary_config_entry=device["primary_config_entry"], + has_composite_identifiers=device["has_composite_identifiers"], serial_number=device["serial_number"], sw_version=device["sw_version"], via_device_id=device["via_device_id"], @@ -1596,13 +2467,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), disabled_by=get_optional_enum( @@ -1616,6 +2482,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], orphaned_timestamp=device["orphaned_timestamp"], + domain=device["domain"], ) self.devices = devices @@ -1645,83 +2512,110 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): } @callback - def async_clear_config_entry(self, config_entry_id: str) -> None: + def _resolve_orphan_domain( + self, config_entry_id: str, domain: str | None + ) -> str | None: + """Return the domain to record on devices orphaned from a config entry.""" + if domain is not None: + return domain + if ( + entry := self.hass.config_entries.async_get_entry(config_entry_id) + ) is not None: + return entry.domain + return None + + @callback + def _async_orphan_deleted_device( + self, deleted_device: DeletedDeviceEntry, domain: str | None, now_time: float + ) -> None: + """Mark a deleted device as orphaned, remembering its former domain.""" + if domain is not None: + # Orphans are indexed by their recorded domain, so two orphans of the + # same domain sharing an identifier or connection would collide. When a + # 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()): + if ( + existing.config_entry_id is None + and existing.domain == domain + and ( + existing.connections & deleted_device.connections + or existing.identifiers & deleted_device.identifiers + ) + ): + del self.deleted_devices[existing.id] + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + config_entry_id=None, + config_subentry_id=None, + orphaned_timestamp=now_time, + domain=domain, + ) + self.async_schedule_save() + + @callback + def async_clear_config_entry( + self, config_entry_id: str, domain: str | None = None + ) -> None: """Clear config entry 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): - self._async_update_device(device.id, remove_config_entry_id=config_entry_id) + self.async_remove_device(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()): + if device.composite_primary_config_entry == config_entry_id: + 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()): + 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()): - config_entries = deleted_device.config_entries - if config_entry_id not in config_entries: + if deleted_device.config_entry_id != config_entry_id: continue - if config_entries == {config_entry_id}: - # Add a time stamp when the deleted device became orphaned - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - orphaned_timestamp=now_time, - config_entries=set(), - config_entries_subentries={}, - ) - else: - config_entries = config_entries - {config_entry_id} - config_entries_subentries = dict( - deleted_device.config_entries_subentries - ) - del config_entries_subentries[config_entry_id] - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_clear_config_subentry( - self, config_entry_id: str, config_subentry_id: str + self, config_entry_id: str, config_subentry_id: str, domain: str | None = None ) -> None: - """Clear config entry from registry entries.""" + """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): - self._async_update_device( - device.id, - remove_config_entry_id=config_entry_id, - remove_config_subentry_id=config_subentry_id, - ) - for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - config_entries_subentries = deleted_device.config_entries_subentries + if device.config_subentry_id != config_subentry_id: + continue + self.async_remove_device(device.id) + # 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()): + pending_move = device._pending_move # noqa: SLF001 if ( - config_entry_id not in config_entries_subentries - or config_subentry_id not in config_entries_subentries[config_entry_id] + 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()): + if ( + deleted_device.config_entry_id != config_entry_id + or deleted_device.config_subentry_id != config_subentry_id ): continue - if config_entries_subentries == {config_entry_id: {config_subentry_id}}: - # We're removing the last config subentry from the last config - # entry, add a time stamp when the deleted device became orphaned - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - orphaned_timestamp=now_time, - config_entries=set(), - config_entries_subentries={}, - ) - else: - config_entries_subentries = config_entries_subentries | { - config_entry_id: config_entries_subentries[config_entry_id] - - {config_subentry_id} - } - if not config_entries_subentries[config_entry_id]: - del config_entries_subentries[config_entry_id] - config_entries = config_entries - {config_entry_id} - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_purge_expired_orphaned_devices(self) -> None: @@ -1821,7 +2715,6 @@ def async_config_entry_disabled_by_changed( the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked DeviceEntryDisabler.CONFIG_ENTRY. - Only disable a device if all associated config entries are disabled. """ devices = async_entries_for_config_entry(registry, config_entry.entry_id) @@ -1833,25 +2726,37 @@ def async_config_entry_disabled_by_changed( registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001 return - enabled_config_entries = { - entry.entry_id - for entry in registry.hass.config_entries.async_entries() - if not entry.disabled_by - } - for device in devices: if device.disabled: # Device already disabled, do not overwrite continue - if len(device.config_entries) > 1 and device.config_entries.intersection( - enabled_config_entries - ): - continue registry._async_update_device( # noqa: SLF001 device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY ) +@callback +def _migrate_device_disabled_by( + device: dict[str, Any], config_entry_disabled: bool +) -> None: + """Reconcile a stored device's disabled_by with its config entry's disabled state. + + Reimplements async_config_entry_disabled_by_changed on stored data so the 1.13 + migration can fix a split device that inherited the composite's disabled_by. Kept in + lockstep with that function by test_migrate_device_disabled_by_matches_runtime; can be + removed in HA Core 2027.8. + """ + disabled_by = device["disabled_by"] + if not config_entry_disabled: + # Config entry enabled: drop a config-entry disable, keep a user/integration one + if disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + device["disabled_by"] = None + return + # Config entry disabled: disable the device unless it is already disabled + if disabled_by is None: + device["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY + + @callback def async_cleanup( hass: HomeAssistant, @@ -1864,8 +2769,7 @@ def async_cleanup( references_config_entries = { device.id for device in dev_reg.devices.values() - for config_entry_id in device.config_entries - if config_entry_id in config_entry_ids + if device.config_entry_id in config_entry_ids } # Find all devices that are referenced in the entity registry. @@ -1883,11 +2787,10 @@ 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 config_entry_id in device.config_entries: - if config_entry_id not in config_entry_ids: - dev_reg._async_update_device( # noqa: SLF001 - device.id, remove_config_entry_id=config_entry_id - ) + 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 + ) # Periodic purge of orphaned devices to avoid the registry # growing without bounds when there are lots of deleted devices diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3683385f7b0a..56fd23d974b1 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -934,9 +934,14 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): Also maintains a count of enabled entries per config entry id. """ - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the container.""" super().__init__() + # hass is stored only so get_entries_for_device_id can expand a pre-migration + # composite device id to its split devices. Remove it, and restore the no-argument + # constructor, once the device registry deprecation period is over and composite + # device ids are no longer resolved. + self._hass = hass self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} self._config_entry_id_index: RegistryIndexType = defaultdict(dict) @@ -1002,13 +1007,36 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): return self._entry_ids.get(key) def get_entries_for_device_id( - self, device_id: str, include_disabled_entities: bool = False + self, + device_id: str, + include_disabled_entities: bool = False, ) -> list[RegistryEntry]: - """Get entries for device.""" + """Get entries for device. + + A device_id may be a pre-migration composite device id, which was split into one + device per config entry. The entries of the split devices are included, so a + lookup by the old composite id still finds the entities that were moved to the + split devices. + """ data = self.data + if keys := self._device_id_index.get(device_id): + # Entities are indexed only under real (live or just-removed) device ids, + # never under a composite device id, so a non-empty bucket means the direct + # result is complete and the device registry can be skipped. + return [ + entry + for key in keys + if not (entry := data[key]).disabled_by or include_disabled_entities + ] + # No directly indexed entities: device_id may be a pre-migration composite device + # id, which resolves to the entities of the split devices it was migrated into. + device_registry = dr.async_get(self._hass) return [ entry - for key in self._device_id_index.get(device_id, ()) + for device in device_registry.async_get_devices_for_composite_device_id( + device_id + ) + for key in self._device_id_index.get(device.id, ()) if not (entry := data[key]).disabled_by or include_disabled_entities ] @@ -1095,7 +1123,7 @@ def _validate_item( ) if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) - if not device_registry.async_get(device_id): + if device_id not in device_registry.devices: raise ValueError(f"Device {device_id} does not exist") if ( disabled_by @@ -1629,36 +1657,32 @@ class EntityRegistry(BaseRegistry): changes = event.data["changes"] - # Remove entities which belong to config entries no longer associated with the - # device - if old_config_entries := changes.get("config_entries"): + # Remove entities which belong to the config entry the device no longer belongs + # to. changes carries the old config_entry_id only when it changed (a move). + if "config_entry_id" in changes: + old_config_entry_id = changes["config_entry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id if ( - entity.config_entry_id in old_config_entries - and entity.config_entry_id not in device.config_entries + entity.config_entry_id == old_config_entry_id + and entity.config_entry_id != device.config_entry_id ): self.async_remove(entity.entity_id) - # Remove entities which belong to config subentries no longer - # associated with the device - if old_config_entries_subentries := changes.get("config_entries_subentries"): + # Remove entities which belong to the config subentry the device no longer + # belongs to. changes carries the old config_subentry_id only when it changed. + if "config_subentry_id" in changes: + old_config_subentry_id = changes["config_subentry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id - config_subentry_id = entity.config_subentry_id if ( - config_entry_id in device.config_entries - and config_entry_id in old_config_entries_subentries - and config_subentry_id - in old_config_entries_subentries[config_entry_id] - and config_subentry_id - not in device.config_entries_subentries[config_entry_id] + entity.config_entry_id == device.config_entry_id + and entity.config_subentry_id == old_config_subentry_id + and entity.config_subentry_id != device.config_subentry_id ): self.async_remove(entity.entity_id) @@ -2011,16 +2035,55 @@ class EntityRegistry(BaseRegistry): async def _async_load(self) -> None: """Load the entity registry.""" # Device registry must be loaded before entity registry because - # migration and entity processing reference device names. - await dr.async_get(self.hass).async_wait_loaded() + # migration and entity processing reference device names, and because entities + # are moved to the correct device when a pre-migration composite device was + # split into one device per config entry. + device_registry = dr.async_get(self.hass) + await device_registry.async_wait_loaded() _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) data = await self._store.async_load() - entities = EntityRegistryItems() + entities = EntityRegistryItems(self.hass) deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {} + # Move entities to the correct device when a pre-migration composite device was + # split into one device per config entry. This can be removed 12 months after + # the config entries split migration ships. + migrated_composite_device = False + + def _split_device_id( + device_id: str | None, + config_entry_id: str | None, + 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 + if device_id is None or device_id in device_registry.devices: + return device_id + successors = device_registry.async_get_devices_for_composite_device_id( + device_id + ) + if not successors: + # The device is gone (e.g. the migration dropped a device with no config + # entry) and was not split; detach the entity rather than leave it pointing + # at a device id that no longer exists. + return None + for successor in successors: + if ( + successor.config_entry_id == config_entry_id + and successor.config_subentry_id == config_subentry_id + ): + return successor.id + for successor in successors: + if successor.config_entry_id == config_entry_id: + return successor.id + # No split device matches the entity's config entry; detach the entity + # rather than move it to an arbitrary split device it does not belong to. + return None + if data is not None: for entity in data["entities"]: try: @@ -2048,11 +2111,19 @@ class EntityRegistry(BaseRegistry): ) continue + device_id = _split_device_id( + entity["device_id"], + entity["config_entry_id"], + entity["config_subentry_id"], + ) + if device_id != entity["device_id"]: + migrated_composite_device = True + original_name_unprefixed = _unprefix_original_name( self.hass, entity["original_name"], entity["has_entity_name"], - entity["device_id"], + device_id, ) entities[entity["entity_id"]] = RegistryEntry( @@ -2065,7 +2136,7 @@ class EntityRegistry(BaseRegistry): config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], - device_id=entity["device_id"], + device_id=device_id, disabled_by=RegistryEntryDisabler(entity["disabled_by"]) if entity["disabled_by"] else None, @@ -2164,6 +2235,10 @@ class EntityRegistry(BaseRegistry): self.entities = entities self._entities_data = entities.data + # Persist entities moved off a split pre-migration composite device + if migrated_composite_device: + self.async_schedule_save() + @override def _data_to_save(self) -> dict[str, Any]: """Return data of entity registry to store in a file.""" @@ -2300,7 +2375,11 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False ) -> list[RegistryEntry]: - """Return entries that match a device.""" + """Return entries that match a device. + + A pre-migration composite device id resolves to the entries of the devices it was + split into. + """ return registry.entities.get_entries_for_device_id( device_id, include_disabled_entities ) diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c433040a6c56..ba9f5191b6c2 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -7,17 +7,18 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity from . import device_registry as dr, entity_registry as er from .event import async_track_entity_registry_updated_event +from .frame import ReportBehavior, report_usage def async_handle_source_entity_changes( hass: HomeAssistant, *, - add_helper_config_entry_to_device: bool = True, helper_config_entry_id: str, set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, + **kwargs: Any, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. @@ -31,11 +32,9 @@ def async_handle_source_entity_changes( called. If the source entity is identified by a UUID, the helper config entry is reloaded. - Source entity moved to another device: The helper entity is updated to link - to the new device, and the helper config entry removed from the old device - and added to the new device. Then the helper config entry is reloaded. + to the new device. Then the helper config entry is reloaded. - Source entity removed from the device: The helper entity is updated to link - to no device, and the helper config entry removed from the old device. Then - the helper config entry is reloaded. + to no device. Then the helper config entry is reloaded. :param set_source_entity_id_or_uuid: A function which updates the source entity ID or UUID, e.g., in the helper config entry options. @@ -43,6 +42,22 @@ def async_handle_source_entity_changes( is removed. This can be used to clean up any resources related to the source entity or ask the user to select a new source entity. """ + if "add_helper_config_entry_to_device" in kwargs: + del kwargs["add_helper_config_entry_to_device"] + # Adding the helper's config entry to the source device is no longer supported + # now that a device belongs to a single config entry; the helper entities link to + # the source device via their device_id instead. + report_usage( + "calls async_handle_source_entity_changes with " + "add_helper_config_entry_to_device, which no longer has any effect", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", + ) + if kwargs: + raise TypeError( + "async_handle_source_entity_changes() got unexpected keyword arguments " + f"{', '.join(map(repr, kwargs))}" + ) async def async_registry_updated( event: Event[er.EventEntityRegistryUpdatedData], @@ -89,9 +104,8 @@ def async_handle_source_entity_changes( # No need to do any cleanup return - # The source entity has been moved to a different device, update the helper - # entities to link to the new device and the helper device to include the - # helper config entry + # The source entity has been moved to a different device; relink the helper + # entities to the new device. for helper_entity in entity_registry.entities.get_entries_for_config_entry_id( helper_config_entry_id ): @@ -100,17 +114,6 @@ def async_handle_source_entity_changes( helper_entity.entity_id, device_id=source_entity_entry.device_id ) - if add_helper_config_entry_to_device: - if source_entity_entry.device_id is not None: - device_registry.async_update_device( - source_entity_entry.device_id, - add_config_entry_id=helper_config_entry_id, - ) - - device_registry.async_update_device( - source_device_id, remove_config_entry_id=helper_config_entry_id - ) - source_device_id = source_entity_entry.device_id # Reload the config entry so the helper entity is recreated with diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 5936aadf2cd0..268cc67e8490 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -245,6 +245,26 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): model_id: str +ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = ( + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + # Filter on properties of the device the entity belongs to + vol.Optional("device"): DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA, + } + ) +) + + +class EntityWithDeviceFilterSelectorConfig(EntityFilterSelectorConfig, total=False): + """Class to represent an entity selector filter config. + + Adds device filtering on top of the shared entity filter, only used by + the entity selector. + """ + + device: DeviceFilterSelectorConfig + + class ActionSelectorConfig(BaseSelectorConfig): """Class to represent an action selector config.""" @@ -985,7 +1005,10 @@ class EntitySelectorConfig( include_entities: list[str] multiple: bool reorder: bool - filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: ( + EntityWithDeviceFilterSelectorConfig + | list[EntityWithDeviceFilterSelectorConfig] + ) @SELECTORS.register("entity") @@ -1004,7 +1027,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, - [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + [ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], ), } ), diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 87eb7041699b..d34151002f11 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -206,8 +206,19 @@ def async_extract_referenced_entity_ids( selected.missing_areas.add(area_id) for device_id in target_selection.device_ids: - if device_id not in dev_reg.devices: + if device_id in dev_reg.devices: + selected.referenced_devices.add(device_id) + elif split_devices := dev_reg.async_get_devices_for_composite_device_id( + device_id + ): + # A multi config entry composite device id is no longer a device itself; + # it resolves to the devices it was split into so actions targeting it + # still trickle down. Only the splits are referenced, not the composite id, + # so a device-id consumer does not act on the same underlying device twice. + selected.referenced_devices.update(device.id for device in split_devices) + else: selected.missing_devices.add(device_id) + selected.referenced_devices.add(device_id) if target_selection.label_ids: label_reg = lr.async_get(hass) @@ -234,7 +245,6 @@ def async_extract_referenced_entity_ids( ) selected.referenced_areas.update(target_selection.area_ids) - selected.referenced_devices.update(target_selection.device_ids) if not selected.referenced_areas and not selected.referenced_devices: return selected diff --git a/homeassistant/helpers/template/extensions/datetime.py b/homeassistant/helpers/template/extensions/datetime.py index 6b388f098fe9..888e9430a32a 100644 --- a/homeassistant/helpers/template/extensions/datetime.py +++ b/homeassistant/helpers/template/extensions/datetime.py @@ -254,6 +254,31 @@ class DateTimeExtension(BaseTemplateExtension): return datetime.combine(today, time_today, today.tzinfo) + def _datetime_as_string(self, value: Any, precision: int, future: bool) -> Any: + """Shared implementation for relative datetime formatting. + + If future is False, formats time since value (past datetime). + If future is True, formats time until value (future datetime). + Returns non-datetime values unmodified. Datetime values that point the + wrong direction are returned as-is, except naive datetimes are first + converted to local time. + """ + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + if not isinstance(value, datetime): + return value + if not value.tzinfo: + value = dt_util.as_local(value) + now = dt_util.now() + if future: + if now > value: + return value + return dt_util.get_time_remaining(value, precision) + if now < value: + return value + return dt_util.get_age(value, precision) + def relative_time(self, value: Any) -> Any: """Take a datetime and return its "age" as a string. @@ -269,16 +294,7 @@ class DateTimeExtension(BaseTemplateExtension): of `time_until`, but is still supported so as not to break old templates. """ - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() < value: - return value - return dt_util.get_age(value) + return self._datetime_as_string(value, precision=1, future=False) def time_since(self, value: Any | datetime, precision: int = 1) -> Any: """Take a datetime and return its "age" as a string. @@ -286,20 +302,11 @@ class DateTimeExtension(BaseTemplateExtension): The age can be in seconds, minutes, hours, days, months and year. precision is the number of units to return, with the last unit rounded. + precision=0 returns all units (no early rounding, except for sub-second values). If the value not a datetime object the input will be returned unmodified. """ - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() < value: - return value - - return dt_util.get_age(value, precision) + return self._datetime_as_string(value, precision=precision, future=False) def time_until(self, value: Any | datetime, precision: int = 1) -> Any: """Take a datetime and return the amount of time until that time as a string. @@ -307,17 +314,8 @@ class DateTimeExtension(BaseTemplateExtension): The time until can be in seconds, minutes, hours, days, months and years. precision is the number of units to return, with the last unit rounded. + precision=0 returns all units (no early rounding, except for sub-second values). If the value not a datetime object the input will be returned unmodified. """ - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() > value: - return value - - return dt_util.get_time_remaining(value, precision) + return self._datetime_as_string(value, precision=precision, future=True) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd2b9ffc2b6b..176976e770e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.5 +home-assistant-frontend==20260624.6 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.26 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 8ca2ef7fef11..173d792ba6e3 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -11,6 +11,7 @@ from homeassistant import runner from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import homeassistant as hass_auth from homeassistant.config import get_default_config_dir +from homeassistant.config_entries import ConfigEntries from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -55,6 +56,9 @@ def run(args: Sequence[str] | None) -> None: async def run_command(args: argparse.Namespace) -> None: """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 525201f80b75..635bbca59614 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -300,6 +300,8 @@ async def async_check_config(config_dir): hass = core.HomeAssistant(config_dir) loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await ar.async_load(hass) await dr.async_load(hass) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 837ed794a256..1863544ba172 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -351,9 +351,10 @@ def get_age(date: dt.datetime, precision: int = 1) -> str: The age can be in second, minute, hour, day, month and year. - depth number of units will be returned, with the last unit rounded + precision is the number of units to return, with the last unit rounded. + precision=0 returns all units (no early rounding, except for sub-second values). - The date must be in the past or a ValueException will be raised. + The date must be in the past or a ValueError will be raised. """ delta = (now() - date).total_seconds() @@ -366,13 +367,14 @@ def get_age(date: dt.datetime, precision: int = 1) -> str: def get_time_remaining(date: dt.datetime, precision: int = 1) -> str: - """Take a datetime and return its "age" as a string. + """Take a datetime and return its "time remaining" as a string. - The age can be in second, minute, hour, day, month and year. + The time remaining can be in second, minute, hour, day, month and year. - depth number of units will be returned, with the last unit rounded + precision is the number of units to return, with the last unit rounded. + precision=0 returns all units (no early rounding, except for sub-second values). - The date must be in the future or a ValueException will be raised. + The date must be in the future or a ValueError will be raised. """ delta = (date - now()).total_seconds() diff --git a/mypy.ini b/mypy.ini index 83e1b07f2a33..c8e7c7c64212 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2037,6 +2037,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gatus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.generic_hygrostat.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3127,6 +3137,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.led_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lektrico.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3247,6 +3267,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.litellm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.litterrobot.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3577,16 +3607,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.modbus_connection.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.modem_callerid.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 8cdb0231c569..bee392ebe525 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.5" +FRONTEND_VERSION: Final[str] = "20260624.6" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/pyproject.toml b/pyproject.toml index 7d545f071e6d..9a4dfa5693ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.26", + "uv==0.11.28", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", @@ -648,7 +648,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.15.20" +required-version = ">=0.15.21" [tool.ruff.lint] select = [ diff --git a/requirements.txt b/requirements.txt index cb713db0f214..ae925cb00902 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==7.0.0 +infrared-protocols==7.5.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.26 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/requirements_all.txt b/requirements_all.txt index a5bedc743195..e232e30b3576 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.60.2 +PyViCare==2.61.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==45.6.0 +aioesphomeapi==45.6.1 # homeassistant.components.matrix # homeassistant.components.slack @@ -345,7 +345,7 @@ aiolyric==2.1.1 aiomealie==1.2.4 # homeassistant.components.melcloud_home -aiomelcloudhome==0.1.9 +aiomelcloudhome==0.2.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -420,7 +420,7 @@ aiorussound==5.0.1 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.26.2 +aioshelly==13.27.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -1080,6 +1080,9 @@ gardena-bluetooth==2.8.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 +# homeassistant.components.gatus +gatus-api==1.0.3 + # homeassistant.components.google gcal-sync==8.0.0 @@ -1230,6 +1233,9 @@ habluetooth==6.26.5 # homeassistant.components.hanna hanna-cloud==0.0.7 +# homeassistant.components.harbor +harbor-python==1.2.1 + # homeassistant.components.cloud hass-nabucasa==2.2.0 @@ -1278,7 +1284,7 @@ hole==0.9.2 holidays==0.100 # homeassistant.components.frontend -home-assistant-frontend==20260624.5 +home-assistant-frontend==20260624.6 # homeassistant.components.conversation home-assistant-intents==2026.6.24 @@ -1371,7 +1377,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==7.0.0 +infrared-protocols==7.5.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 @@ -1380,7 +1386,7 @@ inkbird-ble==1.4.4 insteon-frontend-home-assistant==0.6.2 # homeassistant.components.intellifire -intellifire4py==4.4.0 +intellifire4py==4.5.0 # homeassistant.components.iometer iometer==1.0.2 @@ -1438,7 +1444,7 @@ knocki==0.4.2 knx-frontend==2026.6.23.203726 # homeassistant.components.knx -knx-telegram-store[sqlite]==0.3.2 +knx-telegram-store[sqlite,postgres]==0.10.2 # homeassistant.components.kraken krakenex==2.2.2 @@ -1595,14 +1601,11 @@ millheater==0.14.1 minio==7.1.12 # homeassistant.components.mitsubishi_comfort -mitsubishi-comfort==0.3.2 +mitsubishi-comfort==0.5.0 # homeassistant.components.moat moat-ble==0.1.1 -# homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.4.1 - # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 @@ -1773,6 +1776,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.cloud +# homeassistant.components.litellm # homeassistant.components.llama_cpp # homeassistant.components.open_router # homeassistant.components.openai_conversation @@ -1804,7 +1808,7 @@ openwrt-ubus-rpc==0.0.3 opower==0.18.6 # homeassistant.components.oralb -oralb-ble==1.1.0 +oralb-ble==1.1.1 # homeassistant.components.oru oru==0.1.11 @@ -2024,10 +2028,10 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.3.1 +pyairnow==1.4.0 # homeassistant.components.airobot -pyairobotrest==0.3.0 +pyairobotrest==0.4.0 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro @@ -2112,7 +2116,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.18.1 +pydaikin==2.18.2 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -2136,7 +2140,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2026.4.0 +pydrawise==2026.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 @@ -2457,7 +2461,7 @@ pyotgw==2.2.3 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz[nexity]==2.0.4 +pyoverkiz[nexity]==2.1.0 # homeassistant.components.palazzetti pypalazzetti==0.1.20 @@ -2484,7 +2488,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.38 +pyportainer==1.0.42 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2655,7 +2659,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.8.0 +python-duco-connectivity==0.10.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 @@ -2691,7 +2695,7 @@ python-homewizard-energy==10.1.0 python-hpilo==4.4.3 # homeassistant.components.izone -python-izone==1.3.4 +python-izone==1.3.6 # homeassistant.components.joaoapps_join python-join-api==0.1.1 @@ -2764,7 +2768,7 @@ python-snoo==0.8.3 python-songpal==0.16.2 # homeassistant.components.swisscom -python-swisscom-internet-box==0.1.1 +python-swisscom-internet-box==0.2.0 # homeassistant.components.tado python-tado==0.18.16 @@ -2813,7 +2817,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==1.0.3 +pytrydan==1.0.4 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 @@ -2988,7 +2992,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.14.1 +sense-energy==0.14.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -3261,7 +3265,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.12.1 +uiprotect==15.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 @@ -3314,7 +3318,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx -victron-mqtt==2026.7.0 +victron-mqtt==2026.7.4 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.12 @@ -3472,7 +3476,7 @@ zeversolar==0.3.2 zha-quirks==2.1.1 # homeassistant.components.zha -zha==2.0.0 +zha==2.0.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 8873e7986966..797ef3a7fa85 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.3.4 +syrupy==5.5.2 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8ef2ed276ee8..a4fecbd60d51 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.2 -ruff==0.15.20 +ruff==0.15.21 yamllint==1.38.0 zizmor==1.24.1 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9ca4987719f6..fb23e8b3c957 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -126,6 +126,7 @@ NO_IOT_CLASS = [ "temperature", "timer", "trace", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0a872ddbb8d5..b04a7c801e12 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2076,6 +2076,7 @@ NO_QUALITY_SCALE = [ "timer", "trace", "usage_prediction", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 7369ed7b7310..f9f2d1df308d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -45,7 +45,6 @@ ALLOW_NAME_TRANSLATION = { "local_calendar", "local_ip", "local_todo", - "modbus", "nmap_tracker", "remote_calendar", "rpi_power", diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 19b2ab406674..226f375c2fbd 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN @@ -94,9 +93,9 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 66209f77e6a1..e2d8952396b1 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -30,7 +30,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -81,7 +80,6 @@ async def test_form_invalid_auth( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -133,7 +131,6 @@ async def test_form_cannot_connect( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index cb96c9396c2b..df30a4b766dc 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -204,7 +204,14 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None: }, ) device_registry = mock_device_registry( - hass, {"mock-dev-id": DeviceEntry(id="mock-dev-id", area_id="mock-area-id")} + hass, + { + "mock-dev-id": DeviceEntry( + config_entry_id="mock-config-entry", + id="mock-dev-id", + area_id="mock-area-id", + ) + }, ) policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}} diff --git a/tests/common.py b/tests/common.py index 474863bfaa17..60000058bff4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -292,6 +292,7 @@ async def async_test_home_assistant( ) }, ) + hass.config_entries._initialized.set() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hass.config_entries._async_shutdown, @@ -677,7 +678,7 @@ def mock_registry( if mock_entries is None: mock_entries = {} registry.deleted_entities = {} - registry.entities = er.EntityRegistryItems() + registry.entities = er.EntityRegistryItems(hass) registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): registry.entities[key] = entry @@ -763,7 +764,7 @@ def mock_device_registry( mock_entries = {} for key, entry in mock_entries.items(): registry.devices[key] = entry - registry.deleted_devices = dr.DeviceRegistryItems() + registry.deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry return registry diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 9e3112606936..8ab2584f2948 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'LUNAR-DDEEFF', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 2a1e3dcc7fd7..b5af12e65a2e 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[indoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'I-9PSL', 'name': 'Airgradient', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '84fce612f5b8', 'sw_version': '3.1.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device_info[outdoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': 'O-1PPT', 'name': 'Airgradient', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '84fce612f5b8', 'sw_version': '3.1.1', 'via_device_id': None, diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json index 91029f5531f2..63877e167a90 100644 --- a/tests/components/airnow/fixtures/response.json +++ b/tests/components/airnow/fixtures/response.json @@ -1,47 +1,47 @@ [ { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "O3", - "AQI": 44, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "OZONE", + "nowcastAQI": 44, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM2.5", - "AQI": 37, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM2.5", + "nowcastAQI": 37, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM10", - "AQI": 11, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM10", + "nowcastAQI": 11, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" } ] diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index d711f9c2eba1..72cb584adc6a 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -7,15 +7,15 @@ 'Category.Number': 1, 'DateObserved': '2020-12-20', 'HourObserved': 15, - 'Latitude': '**REDACTED**', + 'Latitude': None, 'LocalTimeZone': 'PST', - 'Longitude': '**REDACTED**', + 'Longitude': None, 'O3': 0.048, 'PM10': 12, 'PM2.5': 6.7, 'Pollutant': 'O3', 'ReportingArea': '**REDACTED**', - 'StateCode': '**REDACTED**', + 'StateCode': '', }), 'entry': dict({ 'data': dict({ diff --git a/tests/components/airobot/snapshots/test_init.ambr b/tests/components/airobot/snapshots/test_init.ambr index b7e2957b834c..7e62c1dff0c2 100644 --- a/tests/components/airobot/snapshots/test_init.ambr +++ b/tests/components/airobot/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'TE1', 'name': 'Test Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.44', 'via_device_id': None, diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py index 59b50b05d31e..4f638122926c 100644 --- a/tests/components/airobot/test_button.py +++ b/tests/components/airobot/test_button.py @@ -71,30 +71,6 @@ async def test_restart_button_error( mock_airobot_client.reboot_thermostat.assert_called_once() -@pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize( - "exception", - [AirobotConnectionError("Connection lost"), AirobotTimeoutError("Timeout")], -) -async def test_restart_button_connection_errors( - hass: HomeAssistant, - mock_airobot_client: AsyncMock, - exception: Exception, -) -> None: - """Test restart button handles connection/timeout errors gracefully.""" - mock_airobot_client.reboot_thermostat.side_effect = exception - - # Should not raise an error - connection errors during reboot are expected - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_thermostat_restart"}, - blocking=True, - ) - - mock_airobot_client.reboot_thermostat.assert_called_once() - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_recalibrate_co2_button( hass: HomeAssistant, diff --git a/tests/components/airvisual_pro/snapshots/test_init.ambr b/tests/components/airvisual_pro/snapshots/test_init.ambr index e2fef8910baa..d0bf6ac327d8 100644 --- a/tests/components/airvisual_pro/snapshots/test_init.ambr +++ b/tests/components/airvisual_pro/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1826', 'via_device_id': None, diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr index e4ae777da32b..ded6fc40e291 100644 --- a/tests/components/alexa_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'echo', 'name': 'Echo Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'echo_test_serial_number', 'sw_version': 'echo_test_software_version', 'via_device_id': None, diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 1a500da5ea85..7d63e61a3aa1 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -158,7 +158,9 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -214,7 +216,9 @@ async def test_invalid_info_skillparameters( """Test invalid info skill service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -278,21 +282,21 @@ async def test_config_entry_not_loaded( async def test_invalid_config_entry( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that a non-existing entry ID in device config entries is skipped.""" + """Test that a device pointing to a non-existing config entry ID is skipped.""" - await setup_integration(hass, mock_config_entry) - - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + device_entry = dr.DeviceEntry( + config_entry_id="non_existing_entry_id", + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) - assert device_entry - - device_entry.config_entries.clear() - device_entry.config_entries.add("non_existing_entry_id") + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( @@ -300,14 +304,14 @@ async def test_invalid_config_entry( "send_sound", { ATTR_SOUND: "bell_02", - ATTR_DEVICE_ID: device_entry.id, + ATTR_DEVICE_ID: TEST_DEVICE_1_ID, }, blocking=True, ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device_entry.id} + assert exc_info.value.translation_placeholders == {"device_id": TEST_DEVICE_1_ID} async def test_missing_config_entry( @@ -316,7 +320,7 @@ async def test_missing_config_entry( mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test missing config entry.""" + """Test that a device not owned by an Alexa config entry is rejected.""" await setup_integration(hass, mock_config_entry) @@ -325,7 +329,15 @@ async def test_missing_config_entry( ) assert device_entry - device_entry.config_entries.clear() + # Move the device to a config entry from a different integration + other_entry = MockConfigEntry(domain="other_domain", data={}) + other_entry.add_to_hass(hass) + device_registry.async_update_device( + device_entry.id, add_config_entry_id=other_entry.entry_id + ) + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=mock_config_entry.entry_id + ) # Call Service with pytest.raises(ServiceValidationError) as exc_info: diff --git a/tests/components/anthemav/snapshots/test_init.ambr b/tests/components/anthemav/snapshots/test_init.ambr index 1bd187b1c9fb..ecda7f5df8af 100644 --- a/tests/components/anthemav/snapshots/test_init.ambr +++ b/tests/components/anthemav/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Anthem AV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 3c1505ff54f3..7a2b1379dcb6 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -716,7 +716,7 @@ async def test_migration_from_v2_1_to_v2_2( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index 057619a02463..ec7af18eef8f 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': 'basement', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'My water heater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'serial', 'sw_version': '2.14', 'via_device_id': None, diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr index 3309d384ec75..e51ed8bfad07 100644 --- a/tests/components/apcupsd/snapshots/test_init.ambr +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_async_setup_entry[mock_request_status0-mocked-config-entry-id][device_MyUPS_XXXXXXXXXXXX] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'MyUPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXXXXXXXXXX', 'sw_version': '3.14.14 (31 May 2016) unknown', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_async_setup_entry[mock_request_status1-mocked-config-entry-id][device_APC UPS_XXXX] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXX', 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_async_setup_entry[mock_request_status2-mocked-config-entry-id][device_APC UPS_] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_async_setup_entry[mock_request_status3-mocked-config-entry-id][device_APC UPS_Blank] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/aprilaire/snapshots/test_init.ambr b/tests/components/aprilaire/snapshots/test_init.ambr index e4fb26e5272c..96711abda099 100644 --- a/tests/components/aprilaire/snapshots/test_init.ambr +++ b/tests/components/aprilaire/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Aprilaire', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.05', 'via_device_id': None, diff --git a/tests/components/aqvify/snapshots/test_init.ambr b/tests/components/aqvify/snapshots/test_init.ambr index fe3d2c055820..1cef36dab3f8 100644 --- a/tests/components/aqvify/snapshots/test_init.ambr +++ b/tests/components/aqvify/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://app.aqvify.com', 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'Device 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'DeviceKey_1', 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://app.aqvify.com', 'connections': set({ }), @@ -54,7 +53,6 @@ 'model_id': None, 'name': 'Device 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'DeviceKey_2', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/asuswrt/snapshots/test_init.ambr b/tests/components/asuswrt/snapshots/test_init.ambr index 6b344d260c39..8f0901e3a5ad 100644 --- a/tests/components/asuswrt/snapshots/test_init.ambr +++ b/tests/components/asuswrt/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://myrouter.asuswrt.com:80', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'myrouter.asuswrt.com', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'FAKE_FIRMWARE', 'via_device_id': None, diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index 9d94ae9ffdc6..456a4468708d 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -2,8 +2,8 @@ # name: test_doorbell_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.august.com', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'tmt100 Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 8af45cae68c8..a9f1292c9b43 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.august.com', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'online_with_doorsense Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, diff --git a/tests/components/aws_s3/snapshots/test_sensor.ambr b/tests/components/aws_s3/snapshots/test_sensor.ambr index ed0d8379e987..09fb233a6af9 100644 --- a/tests/components/aws_s3/snapshots/test_sensor.ambr +++ b/tests/components/aws_s3/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensor.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Bucket test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 663c52dd36c4..20531509f452 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_entry[api_discovery_items0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', 'sw_version': '9.10.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device_registry_entry[api_discovery_items1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', 'sw_version': '9.80.1', 'via_device_id': None, diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index ea9585f0a746..1ba01a7ef02a 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -62,7 +62,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: product = feature.product type(product).name = PropertyMock(return_value="My input sensor") type(product).model = PropertyMock(return_value="inputSensorD") - return feature, "binary_sensor.my_input_sensor" + return feature, "binary_sensor.my_input_sensor_input" @pytest.mark.parametrize( @@ -87,7 +87,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: pytest.param( "inputsensor", "BleBox-inputSensorD-aa11bb22cc33-0.input", - "My input sensor", + "My input sensor Input", None, STATE_ON, "My input sensor", diff --git a/tests/components/blebox/test_button.py b/tests/components/blebox/test_button.py index 6e9a5c3323bb..1ec63623141b 100644 --- a/tests/components/blebox/test_button.py +++ b/tests/components/blebox/test_button.py @@ -7,17 +7,16 @@ import blebox_uniapi import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .conftest import async_setup_entity, mock_feature query_translation_key_matching = [ - ("up", "up"), - ("down", "down"), - ("fav", "fav"), - ("open", "open"), - ("close", "close"), - ("unknown_action", None), + ("up", "up", "button.my_tvliftbox_up", "My tvLiftBox Up"), + ("down", "down", "button.my_tvliftbox_down", "My tvLiftBox Down"), + ("fav", "fav", "button.my_tvliftbox_favorite", "My tvLiftBox Favorite"), + ("open", "open", "button.my_tvliftbox_open", "My tvLiftBox Open"), + ("close", "close", "button.my_tvliftbox_close", "My tvLiftBox Close"), + ("unknown_action", None, "button.my_tvliftbox", "My tvLiftBox"), ] @@ -58,13 +57,15 @@ async def test_tvliftbox_init( @pytest.mark.parametrize( - ("query_string", "expected_translation_key"), + ("query_string", "expected_translation_key", "expected_entity_id", "expected_name"), query_translation_key_matching, ids=[q[0] for q in query_translation_key_matching], ) async def test_button_translation_key( query_string: str, expected_translation_key: str | None, + expected_entity_id: str, + expected_name: str, tvliftbox: tuple[blebox_uniapi.button.Button, str], hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -72,13 +73,12 @@ async def test_button_translation_key( """Test that the correct translation_key is assigned based on query_string.""" caplog.set_level(logging.ERROR) - feature_mock, entity_id = tvliftbox + feature_mock, _ = tvliftbox feature_mock.query_string = query_string - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state is not None - - entity = er.async_get(hass).async_get(entity_id) + entity = await async_setup_entity(hass, expected_entity_id) assert entity is not None assert entity.translation_key == expected_translation_key + + state = hass.states.get(expected_entity_id) + assert state is not None + assert state.name == expected_name diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index fe063ff2100b..ebcfc089d0c2 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -16,6 +16,7 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + BluetoothCallbackReplay, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, @@ -45,6 +46,7 @@ from homeassistant.components.bluetooth.match import ( MANUFACTURER_ID, SERVICE_DATA_UUID, SERVICE_UUID, + BluetoothCallbackMatcher, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -1503,6 +1505,113 @@ async def test_register_callbacks( assert service_info.manufacturer_id == 89 +@pytest.mark.parametrize( + ("devices", "matcher", "replay", "expected_addresses"), + [ + pytest.param( + [ + ("AA:BB:CC:DD:EE:01", "older", 1000.0), + ("AA:BB:CC:DD:EE:02", "newer", 2000.0), + ], + None, + BluetoothCallbackReplay.OLDEST_FIRST, + ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"], + id="oldest-first", + ), + pytest.param( + [ + ("AA:BB:CC:DD:EE:01", "older", 1000.0), + ("AA:BB:CC:DD:EE:02", "newer", 2000.0), + ], + None, + BluetoothCallbackReplay.NEWEST_FIRST, + ["AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:01"], + id="newest-first", + ), + pytest.param( + [ + ("AA:BB:CC:DD:EE:01", "target", 1000.0), + ("AA:BB:CC:DD:EE:02", "other", 2000.0), + ("AA:BB:CC:DD:EE:03", "target", 3000.0), + ], + {LOCAL_NAME: "target"}, + BluetoothCallbackReplay.NEWEST_FIRST, + ["AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:01"], + id="newest-first-with-filter", + ), + pytest.param( + [ + ("AA:BB:CC:DD:EE:01", "older", 1000.0), + ("AA:BB:CC:DD:EE:02", "newer", 2000.0), + ], + None, + BluetoothCallbackReplay.DISABLED, + [], + id="disabled", + ), + pytest.param( + [ + ("AA:BB:CC:DD:EE:01", "target", 1000.0), + ("AA:BB:CC:DD:EE:02", "other", 2000.0), + ], + {ADDRESS: "AA:BB:CC:DD:EE:01"}, + BluetoothCallbackReplay.NEWEST_FIRST, + ["AA:BB:CC:DD:EE:01"], + id="newest-first-address-match", + ), + pytest.param( + [ + ("AA:BB:CC:DD:EE:01", "newer", 2000.0), + ("AA:BB:CC:DD:EE:02", "older", 1000.0), + ], + None, + BluetoothCallbackReplay.OLDEST_FIRST, + ["AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:01"], + id="oldest-first-out-of-order-insertion", + ), + ], +) +@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start") +async def test_register_callbacks_history_replay_order( + hass: HomeAssistant, + devices: list[tuple[str, str, float]], + matcher: BluetoothCallbackMatcher | None, + replay: BluetoothCallbackReplay, + expected_addresses: list[str], +) -> None: + """History replay respects the replay order kwarg.""" + mock_bt = [] + replayed: list[BluetoothServiceInfo] = [] + + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + await async_setup_with_default_adapter(hass) + + with patch.object(hass.config_entries.flow, "async_init"): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + for address, name, adv_time in devices: + device = generate_ble_device(address, name) + adv = generate_advertisement_data(local_name=name) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, adv_time, SOURCE_LOCAL, True + ) + + def _subscriber( + service_info: BluetoothServiceInfo, change: BluetoothChange + ) -> None: + replayed.append(service_info) + + cancel = bluetooth.async_register_callback( + hass, _subscriber, matcher, BluetoothScanningMode.ACTIVE, replay=replay + ) + cancel() + + assert [si.address for si in replayed] == expected_addresses + + @pytest.mark.usefixtures("enable_bluetooth") async def test_register_callbacks_raises_exception( hass: HomeAssistant, diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 39c3350d4b97..cefbffd0a849 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -436,7 +436,9 @@ async def test_setup_component_config( await setup_platform_cb() all_calendar_entities = hass.states.async_entity_ids("calendar") - assert all_calendar_entities == expected_entities + # Entities are added after a concurrent first refresh, so order is not + # guaranteed; assert on the set of created entities instead. + assert sorted(all_calendar_entities) == sorted(expected_entities) @pytest.mark.parametrize("tz", [UTC]) diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 69a49e0fcbe7..466111e9a641 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -292,6 +292,8 @@ async def test_add_item( target={ATTR_ENTITY_ID: TEST_ENTITY}, blocking=True, ) + # Wait for the fire-and-forget state refresh + await hass.async_block_till_done() assert calendar.save_todo.call_args assert calendar.save_todo.call_args.kwargs == expcted_save_args @@ -518,6 +520,8 @@ async def test_update_item( target={ATTR_ENTITY_ID: TEST_ENTITY}, blocking=True, ) + # Wait for the fire-and-forget state refresh + await hass.async_block_till_done() assert dav_client.put.call_args ics = dav_client.put.call_args.args[1] @@ -796,13 +800,17 @@ async def test_subscribe( target={ATTR_ENTITY_ID: TEST_ENTITY}, blocking=True, ) + await hass.async_block_till_done() + + # An earlier state write may re-publish the pre-update list; read until the + # refreshed item arrives. + items = [] + while not items or items[0]["summary"] != "Milk": + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") - # Verify update is published - msg = await client.receive_json() - assert msg["id"] == subscription_id - assert msg["type"] == "event" - items = msg["event"].get("items") - assert items assert len(items) == 1 assert items[0]["summary"] == "Milk" assert items[0]["status"] == "needs_action" diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dcd7b1faa835..2864c85a3ddb 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -331,10 +331,14 @@ def target_calendars( label_on_devices = label_registry.async_create("label_on_devices") device_calendar_1 = dr.DeviceEntry( - id="device_calendar_1", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_1", + labels=[label_on_devices.label_id], ) device_calendar_2 = dr.DeviceEntry( - id="device_calendar_2", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_2", + labels=[label_on_devices.label_id], ) mock_device_registry( hass, diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 226426353755..83dec59d147b 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.20.218', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Cambridge Audio CXNv2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0020c2d8', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/casper_glow/snapshots/test_init.ambr b/tests/components/casper_glow/snapshots/test_init.ambr index 235ab505621d..5ffd48e71d33 100644 --- a/tests/components/casper_glow/snapshots/test_init.ambr +++ b/tests/components/casper_glow/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': 'G01', 'name': 'Jar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/chess_com/snapshots/test_init.ambr b/tests/components/chess_com/snapshots/test_init.ambr index 32d14022d105..2097ba150e13 100644 --- a/tests/components/chess_com/snapshots/test_init.ambr +++ b/tests/components/chess_com/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Joost', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/common.py b/tests/components/common.py index c8e0a869aa4d..98087ae1e18d 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -90,7 +90,12 @@ async def target_entities( "Test Label" ) - device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id}) + device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="test_device", + area_id=area.id, + labels={label.label_id}, + ) mock_device_registry(hass, {device.id: device}) entity_reg = er.async_get(hass) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 4c0f5f18e3bc..153d4f5c685f 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -61,6 +61,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -84,6 +86,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [], "created_at": utcnow().timestamp(), @@ -119,6 +123,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -307,7 +313,7 @@ async def test_remove_config_entry_from_device( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -315,11 +321,14 @@ async def test_remove_config_entry_from_device( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -327,26 +336,21 @@ async def test_remove_config_entry_from_device( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } + async def test_remove_config_entry_from_device_fails( hass: HomeAssistant, @@ -396,38 +400,38 @@ async def test_remove_config_entry_from_device_fails( entry_3.supports_remove_device = True entry_3.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_3 = device_registry.async_get_or_create( config_entry_id=entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - entry_1.entry_id, - entry_2.entry_id, - entry_3.entry_id, - } + # Identifiers and connections are unique per config entry, so each config entry + # gets its own device even though they share a connection + assert device_entry_1.config_entries == {entry_1.entry_id} + assert device_entry_2.config_entries == {entry_2.entry_id} + assert device_entry_3.config_entries == {entry_3.entry_id} fake_entry_id = "abc123" assert entry_1.entry_id != fake_entry_id fake_device_id = "abc123" - assert device_entry.id != fake_device_id + assert device_entry_3.id != fake_device_id # Try removing a non existing config entry from the device - response = await ws_client.remove_device(device_entry.id, fake_entry_id) + response = await ws_client.remove_device(device_entry_3.id, fake_entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry_1.id, entry_1.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -443,22 +447,22 @@ async def test_remove_config_entry_from_device_fails( assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) - - assert response["success"] - assert set(response["result"]["config_entries"]) == { - entry_1.entry_id, - entry_3.entry_id, - } - - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" + # Removing a config entry which supports removal removes the device, since it is + # the device's only config entry + response = await ws_client.remove_device(device_entry_2.id, entry_2.entry_id) + + assert response["success"] + assert response["result"] is None + assert not device_registry.async_get(device_entry_2.id) + # Try removing a config entry which can't be loaded from a device - allowed - response = await ws_client.remove_device(device_entry.id, entry_3.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_3.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -517,7 +521,7 @@ async def test_remove_config_entry_from_device_if_integration_remove( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -525,11 +529,14 @@ async def test_remove_config_entry_from_device_if_integration_remove( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -537,22 +544,17 @@ async def test_remove_config_entry_from_device_if_integration_remove( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index 884ce49edb69..829e3da21da9 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'deCONZ mock gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/denon_rs232/conftest.py b/tests/components/denon_rs232/conftest.py index 02e5aef2b642..5e7a5773cec4 100644 --- a/tests/components/denon_rs232/conftest.py +++ b/tests/components/denon_rs232/conftest.py @@ -99,6 +99,7 @@ def _default_state() -> MockState: digital_input=DigitalInputMode.AUTO, tuner_band=TunerBand.FM, tuner_mode=TunerMode.AUTO, + tuner_frequency="009930", ), zone_2=ZoneState( power=True, diff --git a/tests/components/denon_rs232/snapshots/test_media_player.ambr b/tests/components/denon_rs232/snapshots/test_media_player.ambr index 1fbb93c99cfd..d07b3235148c 100644 --- a/tests/components/denon_rs232/snapshots/test_media_player.ambr +++ b/tests/components/denon_rs232/snapshots/test_media_player.ambr @@ -44,7 +44,7 @@ 'platform': 'denon_rs232', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'receiver', 'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_main', 'unit_of_measurement': None, @@ -70,7 +70,7 @@ 'vcr_2', 'vdp', ]), - : , + : , : 0.5555555555555556, }), 'context': , @@ -137,6 +137,7 @@ 'attributes': ReadOnlyDict({ : 'receiver', : 'AVR-3805 Zone 2', + : '99.30', : 'tuner', : list([ 'cd', diff --git a/tests/components/denon_rs232/test_media_player.py b/tests/components/denon_rs232/test_media_player.py index dc138272c15e..20280c28ade6 100644 --- a/tests/components/denon_rs232/test_media_player.py +++ b/tests/components/denon_rs232/test_media_player.py @@ -8,14 +8,22 @@ from denon_rs232 import InputSource import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.denon_rs232.media_player import INPUT_SOURCE_DENON_TO_HA +from homeassistant.components.denon_rs232.media_player import ( + INPUT_SOURCE_DENON_TO_HA, + TUNER_PRESETS_ROOT, +) from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_CHANNEL, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + MediaType, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -30,13 +38,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) 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 homeassistant.util.json import load_json from .conftest import MockReceiver, MockState, _default_state from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator type ZoneName = Literal["main", "zone_2", "zone_3"] @@ -46,6 +55,9 @@ ZONE_3_ENTITY_ID = "media_player.avr_3805_zone_3" STRINGS_PATH = Path("homeassistant/components/denon_rs232/strings.json") +# The 56 tuner presets the integration exposes, A1 through G8. +TUNER_PRESETS = [f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9)] + @pytest.fixture(autouse=True) async def auto_init_components(init_components) -> None: @@ -310,6 +322,303 @@ async def test_main_invalid_source_raises( ) +@pytest.mark.parametrize( + ("media_id", "expected_command"), + [ + pytest.param("A1", ("TP", "A1"), id="first_preset"), + pytest.param("G8", ("TP", "G8"), id="last_preset"), + pytest.param("C5", ("TP", "C5"), id="preset"), + pytest.param("8750", ("TF", "008750"), id="lowest_frequency"), + pytest.param("10800", ("TF", "010800"), id="highest_frequency"), + pytest.param("9930", ("TF", "009930"), id="frequency"), + pytest.param("009930", ("TF", "009930"), id="padded_frequency"), + pytest.param("00009930", ("TF", "009930"), id="overpadded_frequency"), + pytest.param("0000008750", ("TF", "008750"), id="overpadded_lowest_frequency"), + pytest.param( + "0" * 5000 + "8750", ("TF", "008750"), id="leading_zeros_beyond_int_limit" + ), + ], +) +async def test_main_tuner_play_media( + hass: HomeAssistant, + mock_receiver: MockReceiver, + media_id: str, + expected_command: tuple[str, str], +) -> None: + """Test playing media selects a tuner preset or frequency. + + The default main input source is CD, so this also covers tuning while the + main zone plays another source. + """ + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: media_id, + }, + blocking=True, + ) + assert mock_receiver._send_command.await_args == call(*expected_command) + + +@pytest.mark.parametrize( + ("media_type", "media_id", "translation_key"), + [ + pytest.param( + MediaType.MUSIC, "A1", "unsupported_media_type", id="media_type_not_channel" + ), + pytest.param( + MediaType.CHANNEL, "A", "invalid_tuner_channel", id="media_id_too_short" + ), + pytest.param( + MediaType.CHANNEL, "H1", "invalid_tuner_channel", id="preset_bank_above" + ), + pytest.param( + MediaType.CHANNEL, "A0", "invalid_tuner_channel", id="preset_number_zero" + ), + pytest.param( + MediaType.CHANNEL, "A9", "invalid_tuner_channel", id="preset_number_above" + ), + pytest.param( + MediaType.CHANNEL, "a1", "invalid_tuner_channel", id="preset_lowercase" + ), + pytest.param( + MediaType.CHANNEL, "A1B", "invalid_tuner_channel", id="preset_too_long" + ), + pytest.param( + MediaType.CHANNEL, + "8749", + "invalid_tuner_channel", + id="frequency_below_range", + ), + pytest.param( + MediaType.CHANNEL, + "10801", + "invalid_tuner_channel", + id="frequency_above_range", + ), + pytest.param( + MediaType.CHANNEL, "1000", "invalid_tuner_channel", id="am_frequency" + ), + pytest.param( + MediaType.CHANNEL, + "99.30", + "invalid_tuner_channel", + id="frequency_not_an_integer", + ), + pytest.param( + MediaType.CHANNEL, "not a channel", "invalid_tuner_channel", id="unparsable" + ), + pytest.param( + MediaType.CHANNEL, + "9" * 5000, + "invalid_tuner_channel", + id="frequency_exceeds_int_conversion_limit", + ), + pytest.param( + MediaType.CHANNEL, + "0" * 5000, + "invalid_tuner_channel", + id="zeros_exceed_int_conversion_limit", + ), + ], +) +async def test_main_tuner_play_media_invalid_input_raises( + hass: HomeAssistant, + mock_receiver: MockReceiver, + media_type: MediaType, + media_id: str, + translation_key: str, +) -> None: + """Test playing invalid media raises and sends no tuner command.""" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: media_id, + }, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert mock_receiver._send_command.await_count == 0 + + +@pytest.mark.parametrize("entity_id", [ZONE_2_ENTITY_ID, ZONE_3_ENTITY_ID]) +async def test_zones_do_not_support_play_media( + hass: HomeAssistant, entity_id: str +) -> None: + """Test playing media is rejected for zones, which have no tuner control.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: "A1", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("tuner_frequency", "expected_channel"), + [ + pytest.param("009930", "99.30", id="fm_frequency"), + pytest.param("008750", "87.50", id="lowest_fm_frequency"), + pytest.param("010800", "108.00", id="highest_fm_frequency"), + pytest.param("010000", "100.00", id="whole_mhz_frequency"), + pytest.param(None, None, id="frequency_unknown"), + pytest.param("050000", None, id="am_threshold"), + pytest.param("099990", None, id="am_frequency"), + pytest.param("00AM10", None, id="not_a_number"), + ], +) +async def test_tuner_frequency_media_channel( + hass: HomeAssistant, + mock_receiver: MockReceiver, + tuner_frequency: str | None, + expected_channel: str | None, +) -> None: + """Test the tuner frequency is reported in MHz as the media channel.""" + state = _default_state() + state.main_zone.input_source = InputSource.TUNER + state.main_zone.tuner_frequency = tuner_frequency + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + entity_state = hass.states.get(MAIN_ENTITY_ID) + assert entity_state.attributes.get(ATTR_MEDIA_CHANNEL) == expected_channel + + +async def test_tuner_frequency_not_reported_for_other_sources( + hass: HomeAssistant, mock_receiver: MockReceiver +) -> None: + """Test the media channel is cleared when the zone leaves the tuner source.""" + state = _default_state() + state.main_zone.input_source = InputSource.TUNER + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + assert hass.states.get(MAIN_ENTITY_ID).attributes[ATTR_MEDIA_CHANNEL] == "99.30" + + state = _default_state() + state.main_zone.input_source = InputSource.CD + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + entity_state = hass.states.get(MAIN_ENTITY_ID) + assert ATTR_MEDIA_CHANNEL not in entity_state.attributes + + +async def test_tuner_frequency_shared_by_zones( + hass: HomeAssistant, mock_receiver: MockReceiver +) -> None: + """Test a zone on the tuner source reports the shared main zone frequency.""" + state = _default_state() + state.main_zone.tuner_frequency = "010110" + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + entity_state = hass.states.get(ZONE_2_ENTITY_ID) + assert entity_state.attributes[ATTR_MEDIA_CHANNEL] == "101.10" + + +async def test_browse_media_lists_tuner_presets( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browsing returns every tuner preset as a playable channel.""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "media_player/browse_media", + "entity_id": MAIN_ENTITY_ID, + } + ) + response = await client.receive_json() + + assert response["success"] + result = response["result"] + assert result["media_content_id"] == TUNER_PRESETS_ROOT + assert not result["can_play"] + assert result["can_expand"] + + children = result["children"] + preset_ids = [child["media_content_id"] for child in children] + assert len(preset_ids) == 56 + assert (preset_ids[0], preset_ids[-1]) == ("A1", "G8") + assert preset_ids == TUNER_PRESETS + assert children[0] == { + "title": "A1", + "media_class": "channel", + "media_content_type": "channel", + "media_content_id": "A1", + "can_play": True, + "can_expand": False, + "can_search": False, + "thumbnail": None, + "children_media_class": None, + } + + +async def test_browse_media_invalid_content_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browsing an unknown content id fails.""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "media_player/browse_media", + "entity_id": MAIN_ENTITY_ID, + "media_content_id": "unknown", + } + ) + response = await client.receive_json() + + assert not response["success"] + + +@pytest.mark.parametrize("entity_id", [ZONE_2_ENTITY_ID, ZONE_3_ENTITY_ID]) +async def test_browse_media_not_supported_for_zones( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entity_id: str +) -> None: + """Test only the main zone controls the shared tuner presets.""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "media_player/browse_media", + "entity_id": entity_id, + } + ) + response = await client.receive_json() + + assert not response["success"] + + +async def test_browsed_preset_tunes_when_played( + hass: HomeAssistant, mock_receiver: MockReceiver +) -> None: + """Test every browsed preset is a valid play_media input.""" + for preset in TUNER_PRESETS: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: preset, + }, + blocking=True, + ) + assert mock_receiver._send_command.await_args == call("TP", preset) + + def test_input_source_translation_keys_cover_all_enum_members() -> None: """Test all input sources have a declared translation key.""" assert set(INPUT_SOURCE_DENON_TO_HA) == set(InputSource) diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index f5330670ddd0..0208c1e9dce1 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -137,18 +137,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, derivative_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the derivative config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source device is not removed when the source entity is removed.""" assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() @@ -160,15 +152,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.derivative.async_unload_entry", wraps=derivative.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -177,8 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") assert derivative_entity_entry.device_id is None - # Check that the derivative config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed @@ -380,7 +370,7 @@ async def test_migration_1_2( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.2 removes derivative config entry from device.""" + """Test migration from v1.2 keeps the derivative entity linked to the source device.""" derivative_config_entry = MockConfigEntry( data={}, @@ -399,22 +389,13 @@ async def test_migration_1_2( ) derivative_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() assert derivative_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the derivative config entry is not on the source device and the + # 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") diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d54da57b38af..367a327b81a7 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,5 +1,6 @@ """The test for light device automation.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import attr @@ -11,14 +12,23 @@ from homeassistant import loader from homeassistant.components import automation, device_automation from homeassistant.components.device_automation import ( DOMAIN, + DeviceAutomationType, InvalidDeviceAutomationConfig, toggle_entity, ) +from homeassistant.components.device_automation.helpers import ( + _resolve_device_id, + async_validate_device_automation_config, +) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound from homeassistant.requirements import RequirementsNotFound @@ -1745,3 +1755,137 @@ async def test_async_get_device_automations_platform_reraises_exceptions( await device_automation.async_get_device_automation_platform( hass, "test", device_automation.DeviceAutomationType.TRIGGER ) + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_device_automation_resolves_legacy_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device automation legacy id resolves to the split owning its domain's entry. + + Automations for an entity platform domain are left as the composite id, which the + restored composite device and async_entries_for_device handle directly. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + await ar.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + + # A config-entry domain resolves to the split owning that domain's config entry + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_a") == by_entry[entry_a.entry_id] + ) + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_b") == by_entry[entry_b.entry_id] + ) + + # An entity platform domain is left unresolved, even when a split has such entities + entity_registry.async_get_or_create( + "light", + "domain_a", + "unique", + config_entry=entry_a, + device_id=by_entry[entry_a.entry_id], + ) + assert _resolve_device_id(hass, COMPOSITE_ID, "light") == COMPOSITE_ID + + # An unknown domain is returned unchanged + assert _resolve_device_id(hass, COMPOSITE_ID, "not_present") == COMPOSITE_ID + + +async def test_validate_config_rewrites_composite_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration: None, +) -> None: + """Validating a device automation rewrites a composite id to its domain's split.""" + fake_entry = MockConfigEntry(domain="fake_integration") + fake_entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_fake = device_registry.async_get_or_create( + config_entry_id=fake_entry.entry_id, identifiers={("fake_integration", "1")} + ) + device_other = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "1")} + ) + entity = entity_registry.async_get_or_create( + "light", "fake_integration", "u", device_id=device_fake.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_fake, composite_device_id=old_id + ) + device_registry.devices[device_other.id] = attr.evolve( + device_other, composite_device_id=old_id + ) + assert old_id not in device_registry.devices + + validated = await async_validate_device_automation_config( + hass, + { + "platform": "device", + "domain": "fake_integration", + "device_id": old_id, + "entity_id": entity.entity_id, + "type": "turned_on", + }, + vol.Schema( + {vol.Required("device_id"): str, vol.Required("domain"): str}, + extra=vol.ALLOW_EXTRA, + ), + DeviceAutomationType.TRIGGER, + ) + assert validated["device_id"] == device_fake.id diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 230398378a0e..c2ffa6bcfe4a 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -3,6 +3,7 @@ from collections.abc import Generator from typing import Any +import attr import pytest from homeassistant.components.device_tracker import ( @@ -1611,6 +1612,96 @@ async def test_register_mac_ignored( assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION +async def test_scanner_entity_attaches_to_split_of_composite_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a scanner entity attaches to its config entry's split device.""" + mac = TEST_MAC_ADDRESS + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + old_id = "composite00000000000000000000000" + own_split = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(TEST_DOMAIN, "own")}, + ) + other_split = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "x")}, + ) + # Simulate a migration split: both devices share the pre-migration composite id + device_registry.devices[own_split.id] = attr.evolve( + own_split, composite_device_id=old_id + ) + 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 + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + 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" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Attached to its own split, not the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id == own_split.id + + +async def test_scanner_entity_composite_device_without_own_split( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A composite with no split owned by the scanner's config entry attaches nothing. + + The composite id is not a real device and can't be assigned to an entity, so with no + split to resolve to the entity is added without a device instead of raising. + """ + mac = TEST_MAC_ADDRESS + other_entry_1 = MockConfigEntry(domain="other_1") + other_entry_1.add_to_hass(hass) + other_entry_2 = MockConfigEntry(domain="other_2") + other_entry_2.add_to_hass(hass) + old_id = "composite00000000000000000000000" + # Both splits belong to other config entries, none to the scanner's + for entry, identifier in ((other_entry_1, "one"), (other_entry_2, "two")): + split = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", identifier)}, + ) + device_registry.devices[split.id] = attr.evolve( + split, composite_device_id=old_id + ) + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + 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" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Added without a device rather than raising on the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id is None + + async def test_connected_device_registered( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 69cf0adba2b5..d4539a2dfdd1 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device[mock_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device[mock_ipv6_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://[2001:db8::1]', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, @@ -72,8 +70,8 @@ # name: test_device[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ }), @@ -94,7 +92,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 261372857ade..fe820e8b6d38 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -616,6 +616,46 @@ async def test_setup_and_stop(hass: HomeAssistant) -> None: resolve_iface_call.assert_called_once() +async def test_discovered_service_info(hass: HomeAssistant) -> None: + """Test getting the discovered DHCP devices from the cache.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + if_indexes: list[int] | None = None, + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert dhcp.async_discovered_service_info(hass) == [] + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "Sad", "44:44:33:11:23:13")) + + assert dhcp.async_discovered_service_info(hass) == [ + DhcpServiceInfo( + ip="4.3.2.2", + hostname="happy", + macaddress="444433112312", + ), + DhcpServiceInfo( + ip="4.3.2.1", + hostname="sad", + macaddress="444433112313", + ), + ] + + async def test_setup_fails_as_root( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/diagnostics/test_util.py b/tests/components/diagnostics/test_util.py index 6f1c1b2e1995..004d4d6f904e 100644 --- a/tests/components/diagnostics/test_util.py +++ b/tests/components/diagnostics/test_util.py @@ -5,8 +5,10 @@ from datetime import datetime from homeassistant.components.diagnostics import ( REDACTED, async_redact_data, + device_entry_as_dict, entity_entry_as_dict, ) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -88,3 +90,35 @@ def test_entity_entry_as_dict() -> None: assert result["original_name"] == "Test Sensor" assert result["supported_features"] == 0 assert result["created_at"] == created + + +def test_device_entry_as_dict() -> None: + """Test device_entry_as_dict.""" + created = datetime.fromisoformat("2024-01-01T00:00:00+00:00") + entry = DeviceEntry( + config_entry_id="mock-config-entry-id", + created_at=created, + identifiers={("test", "unique123")}, + modified_at=created, + name="Test Device", + ) + + result = device_entry_as_dict(entry) + + assert isinstance(result, dict) + # Internal bookkeeping and composite-device migration attributes are excluded + for attribute in ( + "_cache", + "_composite_subentries", + "_pending_move", + "_suggested_area", + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", + ): + assert attribute not in result + assert result["config_entry_id"] == "mock-config-entry-id" + assert result["identifiers"] == [["test", "unique123"]] + assert result["name"] == "Test Device" + assert result["created_at"] == created diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index b6963ad8a761..655dd0dfc935 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -23,6 +23,7 @@ from duco_connectivity import ( NodeMotorStateInfo, NodeSensorInfo, NodeVentilationInfo, + VentilationTemperatureInfo, ) import pytest @@ -178,6 +179,17 @@ def mock_lan_info() -> LanInfo: ) +@pytest.fixture +def mock_ventilation_temperature_info() -> VentilationTemperatureInfo: + """Return mock ventilation temperatures in Celsius.""" + return VentilationTemperatureInfo( + temp_oda=5.5, + temp_sup=18.2, + temp_eta=21.4, + temp_eha=8.1, + ) + + @pytest.fixture def mock_nodes() -> list[Node]: """Return a list of nodes covering all supported types.""" @@ -235,6 +247,7 @@ def mock_duco_client( mock_lan_info: LanInfo, mock_nodes: list[Node], mock_node_actions: NodeListActionItemList, + mock_ventilation_temperature_info: VentilationTemperatureInfo, ) -> Generator[AsyncMock]: """Return a mocked DucoClient used by both the integration and config flow.""" with ( @@ -255,6 +268,9 @@ def mock_duco_client( client.async_get_node_configs.return_value = node_configs_from_nodes(mock_nodes) client.async_get_node_actions.return_value = mock_node_actions client.async_get_time_filter_remaining.return_value = 180 + client.async_get_ventilation_temperature_info.return_value = ( + mock_ventilation_temperature_info + ) client.async_get_diagnostics.return_value = [ DiagComponent(component="Ventilation", status="Ok") ] diff --git a/tests/components/duco/snapshots/test_sensor.ambr b/tests/components/duco/snapshots/test_sensor.ambr index 3b2801d29af0..1be688fcc237 100644 --- a/tests/components/duco/snapshots/test_sensor.ambr +++ b/tests/components/duco/snapshots/test_sensor.ambr @@ -835,6 +835,122 @@ 'state': '90', }) # --- +# name: test_sensor_entities_state[sensor.living_exhaust_air_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.living_exhaust_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Exhaust air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_exhaust_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_exhaust_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Exhaust air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_exhaust_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.1', + }) +# --- +# name: test_sensor_entities_state[sensor.living_extract_air_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.living_extract_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Extract air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'extract_air_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_extract_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_extract_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Extract air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_extract_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.4', + }) +# --- # name: test_sensor_entities_state[sensor.living_filter_remaining-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -890,6 +1006,64 @@ 'state': '180', }) # --- +# name: test_sensor_entities_state[sensor.living_outdoor_air_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.living_outdoor_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor air temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_air_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_outdoor_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_outdoor_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Outdoor air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_outdoor_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- # name: test_sensor_entities_state[sensor.living_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -996,6 +1170,64 @@ 'state': 'unknown', }) # --- +# name: test_sensor_entities_state[sensor.living_supply_air_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.living_supply_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Supply air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply air temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_supply_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_supply_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Supply air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_supply_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- # name: test_sensor_entities_state[sensor.living_target_flow_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 24f906507d37..3cf9aee5b3c9 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -15,10 +15,12 @@ from duco_connectivity import ( LanInfo, Node, NodeListActionItemList, + VentilationTemperatureInfo, ) from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.duco.const import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -158,6 +160,42 @@ async def test_setup_entry_ignores_lan_info_failures( assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize( + "exception", + [ + pytest.param(DucoError("API error"), id="duco_error"), + pytest.param(DucoConnectionError("Connection refused"), id="connection_error"), + ], +) +async def test_setup_entry_recovers_from_optional_temperature_capability_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + exception: Exception, +) -> None: + """Test an optional temperature capability is retried after a setup failure.""" + mock_duco_client.async_get_ventilation_temperature_info.side_effect = [ + exception, + VentilationTemperatureInfo(temp_oda=5.5), + ] + 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 + assert hass.states.get("sensor.living_outdoor_air_temperature") is None + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.living_outdoor_air_temperature") + assert state is not None + assert state.state == "5.5" + + async def test_setup_entry_ignores_node_name_config_failures( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -280,6 +318,9 @@ async def test_setup_entry_creates_http_client( mock_client_class.return_value.async_get_node_actions.return_value = ( mock_node_actions ) + ( + mock_client_class.return_value.async_get_ventilation_temperature_info.return_value + ) = VentilationTemperatureInfo() mock_client_class.return_value.async_get_diagnostics.return_value = [ DiagComponent(component="Ventilation", status="Ok") ] diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 563ab57fb6b7..f3c978d0822b 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -7,12 +7,14 @@ from unittest.mock import AsyncMock from duco_connectivity import ( DucoConnectionError, DucoError, + DucoUnsupportedCapabilityError, Node, NodeGeneralInfo, NodeSensorInfo, NodeType, NodeVentilationInfo, VentilationState, + VentilationTemperatureInfo, ) from freezegun.api import FrozenDateTimeFactory import pytest @@ -28,6 +30,12 @@ from . import setup_platform_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform FILTER_REMAINING_ENTITY_ID = "sensor.living_filter_remaining" +VENTILATION_TEMPERATURE_ENTITY_IDS = ( + "sensor.living_outdoor_air_temperature", + "sensor.living_supply_air_temperature", + "sensor.living_extract_air_temperature", + "sensor.living_exhaust_air_temperature", +) @pytest.mark.parametrize( @@ -223,6 +231,55 @@ async def test_time_filter_remaining_missing_skips_sensor_creation( assert hass.states.get(FILTER_REMAINING_ENTITY_ID) is None +async def test_ventilation_temperatures_missing_skip_sensor_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test unsupported ventilation temperatures never expose temperature states.""" + mock_duco_client.async_get_ventilation_temperature_info.side_effect = [ + DucoUnsupportedCapabilityError(400, "/info", '{"Code":3,"Result":"FAILED"}'), + VentilationTemperatureInfo(temp_oda=5.5), + ] + + await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR]) + + for entity_id in VENTILATION_TEMPERATURE_ENTITY_IDS: + assert hass.states.get(entity_id) is None + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + for entity_id in VENTILATION_TEMPERATURE_ENTITY_IDS: + assert hass.states.get(entity_id) is None + + +async def test_partial_ventilation_temperatures_only_expose_available_sensor_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test only populated ventilation temperature fields are exposed as states.""" + mock_duco_client.async_get_ventilation_temperature_info.return_value = ( + VentilationTemperatureInfo(temp_oda=5.5, temp_eta=21.4) + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR]) + + state = hass.states.get("sensor.living_outdoor_air_temperature") + assert state is not None + assert state.state == "5.5" + + state = hass.states.get("sensor.living_extract_air_temperature") + assert state is not None + assert state.state == "21.4" + + assert hass.states.get("sensor.living_supply_air_temperature") is None + assert hass.states.get("sensor.living_exhaust_air_temperature") is None + + async def test_time_filter_remaining_transient_failure_recovers_sensor_creation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/eafm/snapshots/test_init.ambr b/tests/components/eafm/snapshots/test_init.ambr index 39a5978315c8..eb58e3e1be52 100644 --- a/tests/components/eafm/snapshots/test_init.ambr +++ b/tests/components/eafm/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'My station Water Level Stage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/earn_e_p1/snapshots/test_init.ambr b/tests/components/earn_e_p1/snapshots/test_init.ambr index 9e04b5e3a66b..8279e82605b9 100644 --- a/tests/components/earn_e_p1/snapshots/test_init.ambr +++ b/tests/components/earn_e_p1/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'EARN-E P1 Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'E0012345678901234', 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index 0e847da73ad7..2b20d774c0a3 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices_in_dr[E1234567890000000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'yna5xi', 'name': 'Ozmo 950', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'E1234567890000000001', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py index 25eec8b6654a..7cc22ec7b2a2 100644 --- a/tests/components/edifier_infrared/conftest.py +++ b/tests/components/edifier_infrared/conftest.py @@ -77,6 +77,11 @@ def mock_edifier_code_to_command() -> Generator[None]: autospec=True, side_effect=lambda self: self, ), + patch( + "infrared_protocols.codes.edifier.s3000pro.EdifierS3000ProCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), ): yield diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py index f12b3b33899e..119cfa8ad0c1 100644 --- a/tests/components/edifier_infrared/test_config_flow.py +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -26,6 +26,7 @@ from tests.components.infrared import EMITTER_ENTITY_ID (EdifierModel.R1280T, EdifierCommandSet.R1280T), (EdifierModel.S360DB, EdifierCommandSet.S360DB), (EdifierModel.RC20G, EdifierCommandSet.RC20G), + (EdifierModel.S3000PRO, EdifierCommandSet.S3000PRO), ], ) @pytest.mark.usefixtures("mock_infrared_emitter_entity") diff --git a/tests/components/egauge/snapshots/test_sensor.ambr b/tests/components/egauge/snapshots/test_sensor.ambr index d9753970cd96..aaffbdacb28f 100644 --- a/tests/components/egauge/snapshots/test_sensor.ambr +++ b/tests/components/egauge/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors.12 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'egauge-home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ABC123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/electrasmart/snapshots/test_init.ambr b/tests/components/electrasmart/snapshots/test_init.ambr index 97b1d33f77f7..0a6a6bea0920 100644 --- a/tests/components/electrasmart/snapshots/test_init.ambr +++ b/tests/components/electrasmart/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index a7e18e93b641..c255e649e531 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_buttons[button.frenck_identify-identify-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -139,8 +138,8 @@ # name: test_buttons[button.frenck_restart-restart-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -165,7 +164,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 18f8e78da416..97fa93346ef4 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -80,8 +80,8 @@ # name: test_light_state_temperature[key-light-state].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -106,7 +106,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -195,8 +194,8 @@ # name: test_light_state_temperature[light-strip-state-color-temperature].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -221,7 +220,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -310,8 +308,8 @@ # name: test_light_state_temperature[light-strip-state].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -336,7 +334,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index cd079124f2a9..4e0ae5d822ee 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -60,8 +60,8 @@ # name: test_sensors[sensor.frenck_battery-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -86,7 +86,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -156,8 +155,8 @@ # name: test_sensors[sensor.frenck_battery_voltage-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -182,7 +181,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -252,8 +250,8 @@ # name: test_sensors[sensor.frenck_charging_current-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -278,7 +276,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -345,8 +342,8 @@ # name: test_sensors[sensor.frenck_charging_power-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -371,7 +368,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -441,8 +437,8 @@ # name: test_sensors[sensor.frenck_charging_voltage-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -467,7 +463,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 71b5c3dc3d44..4d48cc9bb37d 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_switches[switch.frenck_energy_saving-energy_saving-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -78,7 +78,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -137,8 +136,8 @@ # name: test_switches[switch.frenck_studio_mode-battery_bypass-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +162,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/energieleser/conftest.py b/tests/components/energieleser/conftest.py index 21e8874273ad..f26a1c4117de 100644 --- a/tests/components/energieleser/conftest.py +++ b/tests/components/energieleser/conftest.py @@ -1,6 +1,7 @@ """Fixtures for energieleser integration tests.""" from collections.abc import Generator +from dataclasses import replace from unittest.mock import AsyncMock, patch from energieleser import ( @@ -78,6 +79,14 @@ def mock_stromleser_device() -> StromleserOneDevice: return StromleserOneDevice.from_payload(STROMLESER_API_RESPONSE) +@pytest.fixture +def mock_locked_stromleser_device( + mock_stromleser_device: StromleserOneDevice, +) -> StromleserOneDevice: + """Return a stromleser device with PIN locked.""" + return replace(mock_stromleser_device, pin_locked=True) + + @pytest.fixture def mock_gasleser_device() -> GasleserDevice: """Return a parsed gasleser device built from the API fixture.""" diff --git a/tests/components/energieleser/snapshots/test_diagnostics.ambr b/tests/components/energieleser/snapshots/test_diagnostics.ambr new file mode 100755 index 000000000000..863d1f582cb8 --- /dev/null +++ b/tests/components/energieleser/snapshots/test_diagnostics.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_entry_diagnostics[gasleser] + dict({ + 'count': 603, + 'current_flow_rate': 0.01, + 'device_id': '**REDACTED**', + 'device_type': 'gasleser', + 'signal_strength_dbm': -51.0, + 'timestamp': 1776179005, + 'total_consumption': 37030.67, + }) +# --- +# name: test_entry_diagnostics[stromleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'stromleser', + 'energy_export': dict({ + 'unit': 'Wh', + 'value': 26561.0, + }), + 'energy_export_tariff_1': None, + 'energy_export_tariff_2': None, + 'energy_export_tariff_3': None, + 'energy_export_tariff_4': None, + 'energy_import': dict({ + 'unit': 'Wh', + 'value': 12345.0, + }), + 'energy_import_tariff_1': None, + 'energy_import_tariff_2': None, + 'energy_import_tariff_3': None, + 'energy_import_tariff_4': None, + 'pin_locked': False, + 'power_absolute': None, + 'power_active': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'power_export': None, + 'power_import': None, + 'power_l1': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l2': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l3': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'signal_strength_dbm': -51.0, + 'timestamp': 1776178480, + }) +# --- +# name: test_entry_diagnostics[waermeleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'waermeleser', + 'fabrication_number': '**REDACTED**', + 'flow_temperature': dict({ + 'unit': '°C', + 'value': 16.9, + }), + 'power': dict({ + 'unit': 'kW', + 'value': 2.31, + }), + 'return_temperature': dict({ + 'unit': '°C', + 'value': 19.6, + }), + 'signal_strength_dbm': -51.0, + 'temperature_difference': dict({ + 'unit': 'K', + 'value': 2.68, + }), + 'timestamp': 1747285200, + 'total_energy_t1': dict({ + 'unit': 'MWh', + 'value': 34.09, + }), + 'total_energy_t2': dict({ + 'unit': 'MWh', + 'value': 12.45, + }), + 'total_energy_t3': dict({ + 'unit': 'MWh', + 'value': 5.67, + }), + 'total_volume': dict({ + 'unit': 'm³', + 'value': 3561.23, + }), + 'volume_flow': dict({ + 'unit': 'l/h', + 'value': 1.23, + }), + }) +# --- +# name: test_entry_diagnostics[wasserleser] + dict({ + 'current_flow_rate': dict({ + 'unit': 'l/h', + 'value': 0.0, + }), + 'current_flow_rate_m3': dict({ + 'unit': 'm3/h', + 'value': 0.0, + }), + 'device_id': '**REDACTED**', + 'device_type': 'wasserleser', + 'signal_strength_dbm': -49.0, + 'timestamp': 1779276532, + 'today_consumption': dict({ + 'unit': 'm3', + 'value': 0.0, + }), + 'total_consumption': dict({ + 'unit': 'm3', + 'value': 123.755, + }), + }) +# --- diff --git a/tests/components/energieleser/test_diagnostics.py b/tests/components/energieleser/test_diagnostics.py new file mode 100755 index 000000000000..61795c4615af --- /dev/null +++ b/tests/components/energieleser/test_diagnostics.py @@ -0,0 +1,55 @@ +"""Test energieleser diagnostics.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("device_fixture", "config_entry_fixture"), + [ + pytest.param( + "mock_stromleser_device", "mock_stromleser_config_entry", id="stromleser" + ), + pytest.param( + "mock_gasleser_device", "mock_gasleser_config_entry", id="gasleser" + ), + pytest.param( + "mock_waermeleser_device", + "mock_waermeleser_config_entry", + id="waermeleser", + ), + pytest.param( + "mock_wasserleser_device", + "mock_wasserleser_config_entry", + id="wasserleser", + ), + ], +) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_energieleser_client: AsyncMock, + device_fixture: str, + config_entry_fixture: str, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + device = request.getfixturevalue(device_fixture) + config_entry = request.getfixturevalue(config_entry_fixture) + + mock_energieleser_client.get_device.return_value = device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot diff --git a/tests/components/energieleser/test_init.py b/tests/components/energieleser/test_init.py old mode 100755 new mode 100644 index b620df729924..ce229a0dfc25 --- a/tests/components/energieleser/test_init.py +++ b/tests/components/energieleser/test_init.py @@ -6,18 +6,21 @@ from energieleser import ( EnergieleserConnectionError, EnergieleserError, EnergieleserUnknownDeviceError, + StromleserOneDevice, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.energieleser.const import CONF_SW_VERSION, DOMAIN +from homeassistant.components.energieleser.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from .conftest import STROMLESER_DEVICE_ID, STROMLESER_SW_VERSION -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_energieleser_client") @@ -89,3 +92,62 @@ async def test_device_exposes_discovery_sw_version( ) assert device is not None assert device.sw_version == STROMLESER_SW_VERSION + + +async def test_meter_locked_repair_issue( + hass: HomeAssistant, + mock_energieleser_client: AsyncMock, + mock_stromleser_device: StromleserOneDevice, + mock_locked_stromleser_device: StromleserOneDevice, + mock_stromleser_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test repair issue is created when meter is locked and deleted when unlocked.""" + mock_energieleser_client.get_device.return_value = mock_locked_stromleser_device + mock_stromleser_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"pin_locked_{mock_stromleser_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "meter_locked" + assert ( + issue.learn_more_url + == "https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation" + ) + assert issue.translation_placeholders == { + "device_name": mock_stromleser_config_entry.title, + } + + mock_energieleser_client.get_device.return_value = mock_stromleser_device + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_meter_locked_repair_issue_removed_on_unload( + hass: HomeAssistant, + mock_energieleser_client: AsyncMock, + mock_locked_stromleser_device: StromleserOneDevice, + mock_stromleser_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is deleted when entry is unloaded.""" + mock_energieleser_client.get_device.return_value = mock_locked_stromleser_device + mock_stromleser_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"pin_locked_{mock_stromleser_config_entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + assert await hass.config_entries.async_unload(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index b27e00a747cd..dee465efea12 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -30,14 +30,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -57,7 +51,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -284,14 +277,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -311,7 +298,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -944,14 +930,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -971,7 +951,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -1198,14 +1177,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1225,7 +1198,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -1918,14 +1890,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1945,7 +1911,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -2172,14 +2137,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2199,7 +2158,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -2921,14 +2879,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2948,7 +2900,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -3491,14 +3442,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ list([ @@ -3522,7 +3467,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -3844,14 +3788,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -3871,7 +3809,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -4414,14 +4351,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4441,7 +4372,6 @@ 'model_id': None, 'name': 'Collar 482520020939', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482520020939', 'sw_version': '3.0.6-D0', }), @@ -4725,14 +4655,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4752,7 +4676,6 @@ 'model_id': None, 'name': 'C6 Combiner 482523040549', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482523040549', 'sw_version': '0.1.20-D1', }), @@ -4852,14 +4775,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4879,7 +4796,6 @@ 'model_id': None, 'name': 'Enpower 654321', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '654321', 'sw_version': '1.2.2064_release/20.34', }), @@ -5273,14 +5189,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -5300,7 +5210,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.1.2', }), @@ -18167,14 +18076,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18194,7 +18097,6 @@ 'model_id': None, 'name': 'Encharge <>56', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>56', 'sw_version': '2.6.5973_rel/22.11', }), @@ -18543,14 +18445,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18570,7 +18466,6 @@ 'model_id': None, 'name': 'NC1 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -18956,14 +18851,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18983,7 +18872,6 @@ 'model_id': None, 'name': 'NC2 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -19369,14 +19257,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -19396,7 +19278,6 @@ 'model_id': None, 'name': 'NC3 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index bfb6aa97446f..8060f6aafe31 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -210,7 +210,7 @@ def mock_client(mock_device_info) -> Generator[APIClient]: "homeassistant.components.esphome.manager.ReconnectLogic", BaseMockReconnectLogic, ), - patch("homeassistant.components.esphome.APIClient", mock_client), + patch("homeassistant.components.esphome.manager.APIClient", mock_client), patch("homeassistant.components.esphome.config_flow.APIClient", mock_client), ): yield mock_client diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index dfde80addd55..89aeb811dbc5 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,11 +2,13 @@ import asyncio import base64 +from collections.abc import Generator import logging from typing import Any from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -19,6 +21,7 @@ from aioesphomeapi import ( InvalidAuthAPIError, InvalidEncryptionKeyAPIError, LogLevel, + ReconnectLogic, RequiresEncryptionAPIError, SubDeviceInfo, SupportsResponseType, @@ -32,6 +35,7 @@ import pytest import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.esphome.config_flow import PROBE_NOISE_PSK from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, @@ -179,6 +183,33 @@ async def test_esphome_device_service_calls_not_allowed( ) in caplog.text +@pytest.mark.parametrize("has_deep_sleep", [True, False]) +async def test_reconnect_logic_seeds_deep_sleep_from_restored_device_info( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + has_deep_sleep: bool, +) -> None: + """Restored device_info seeds reconnect_logic.deep_sleep before the first connect.""" + deep_sleep_at_start: list[bool] = [] + real_start = ReconnectLogic.start + + async def _start(self: ReconnectLogic) -> None: + # Captured before the first connect refreshes it, so this is the + # pre-populated value from the restored device_info. + deep_sleep_at_start.append(self.deep_sleep) + await real_start(self) + + with patch.object(ReconnectLogic, "start", _start): + await mock_esphome_device( + mock_client=mock_client, + device_info={"has_deep_sleep": has_deep_sleep}, + mock_storage=True, + ) + + assert deep_sleep_at_start == [has_deep_sleep] + + async def test_esphome_device_service_calls_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -2724,6 +2755,208 @@ async def test_manager_handle_dynamic_encryption_key_connection_error( assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] +@pytest.fixture +def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: + """Mock the APIClient built for the zero PSK provisioning connection.""" + client = Mock(spec=APIClient) + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.noise_encryption_set_key = AsyncMock(return_value=True) + + def _api_client(*args: Any, **kwargs: Any) -> Mock: + if kwargs.get("noise_psk") == ZERO_NOISE_PSK: + return client + return mock_client(*args, **kwargs) + + with patch( + "homeassistant.components.esphome.manager.APIClient", side_effect=_api_client + ): + yield client + + +def _make_provisionable_entry(hass: HomeAssistant, mac_address: str) -> MockConfigEntry: + """Create a config entry without a noise PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + return entry + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_provisioned_over_zero_psk( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test provisionable firmware gets the key over a zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + + # The main (plaintext) client must never be used to push the key + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The key went over the zero PSK client (the fixture only hands it out + # for constructions using ZERO_NOISE_PSK), not the plaintext connection + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + base64.b64encode(test_key_bytes) + ) + mock_client.noise_encryption_set_key.assert_not_called() + mock_provisioning_client.disconnect.assert_called_with(force=True) + + # Entry and storage were updated + assert entry.data[CONF_NOISE_PSK] == expected_key + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage( + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test a stored key is re-provisioned over the zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + test_key.encode() + ) + mock_client.noise_encryption_set_key.assert_not_called() + assert entry.data[CONF_NOISE_PSK] == test_key + + +@pytest.mark.parametrize( + ("connect_error", "set_key_result"), + [ + # Device already has a key (distinct log branch) + (InvalidEncryptionKeyAPIError("already keyed"), True), + # Old firmware answering plaintext to the noise hello (generic branch; + # all connection errors are APIConnectionError subclasses) + (EncryptionPlaintextAPIError("plaintext"), True), + # Device accepted the connection but rejected the key + (None, False), + ], +) +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_zero_psk_failures_never_use_plaintext( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + connect_error: Exception | None, + set_key_result: bool, +) -> None: + """Test zero PSK provisioning failures do not fall back to plaintext.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # A None side_effect leaves connect behaving normally + mock_provisioning_client.connect.side_effect = connect_error + mock_provisioning_client.noise_encryption_set_key.return_value = set_key_result + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The plaintext connection was never used to push the key, the entry was + # not updated, and no generated key was stored + mock_client.noise_encryption_set_key.assert_not_called() + assert CONF_NOISE_PSK not in entry.data + assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] + mock_provisioning_client.disconnect.assert_called_with(force=True) + + +def test_zero_noise_psk_is_not_the_probe_key() -> None: + """Test the provisioning PSK is 32 zero bytes and differs from the probe.""" + assert base64.b64decode(ZERO_NOISE_PSK) == bytes(32) + assert ZERO_NOISE_PSK != PROBE_NOISE_PSK + + async def test_zwave_proxy_request_home_id_change( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/essent/snapshots/test_init.ambr b/tests/components/essent/snapshots/test_init.ambr index de1cc10d4f1f..c0bef2d727ae 100644 --- a/tests/components/essent/snapshots/test_init.ambr +++ b/tests/components/essent/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Essent', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/eurotronic_cometblue/snapshots/test_init.ambr b/tests/components/eurotronic_cometblue/snapshots/test_init.ambr index e7a6d8a3ebdc..79c9b95cd142 100644 --- a/tests/components/eurotronic_cometblue/snapshots/test_init.ambr +++ b/tests/components/eurotronic_cometblue/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Comet Blue aa:bb:cc:dd:ee:ff', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0.10', 'via_device_id': None, diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr index 6a242c4d2cec..7e4dc3e64f6b 100644 --- a/tests/components/flo/snapshots/test_init.ambr +++ b/tests/components/flo/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Smart water shutoff', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '6.1.1', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Kitchen sink', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111112', 'sw_version': '1.1.15', 'via_device_id': None, diff --git a/tests/components/fressnapf_tracker/snapshots/test_init.ambr b/tests/components/fressnapf_tracker/snapshots/test_init.ambr index 59d50fa4ce13..721deab34c45 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_init.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_state_entity_device_snapshots[Fluffy-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Fluffy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ABC123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/fumis/snapshots/test_climate.ambr b/tests/components/fumis/snapshots/test_climate.ambr index fb25e5fe4c48..7b60c8b3f06d 100644 --- a/tests/components/fumis/snapshots/test_climate.ambr +++ b/tests/components/fumis/snapshots/test_climate.ambr @@ -71,8 +71,8 @@ # name: test_climate_entity.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -97,7 +97,6 @@ 'model_id': None, 'name': 'Clou Duo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.6.0', 'via_device_id': None, diff --git a/tests/components/gardena_bluetooth/__init__.py b/tests/components/gardena_bluetooth/__init__.py index 1fd27b5ca10e..e6bbc42c25a9 100644 --- a/tests/components/gardena_bluetooth/__init__.py +++ b/tests/components/gardena_bluetooth/__init__.py @@ -46,6 +46,18 @@ AQUA_CONTOUR_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +PRESSURE_TANK_SERVICE_INFO = BluetoothServiceInfo( + name="GARDENA PTU", + address="00000000-0000-0000-0000-000000000004", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x05\x04\x80\x20\x00\x00\x02\x05\x01\x04\x06\x11\x02\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + MISSING_PRODUCT_SERVICE_INFO = BluetoothServiceInfo( name="Missing Product Info", address="00000000-0000-0000-0000-000000000000", diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 20b246609c70..02414fc0750d 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup[Aqua Contour] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'My contour', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.0', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_setup[Timer] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'My timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.2.3', 'via_device_id': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 0da2fb18f9ff..2eecb7e525cf 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -484,6 +484,125 @@ 'state': '2023-01-01T01:01:40+00:00', }) # --- +# name: test_sensors[pressure_tank][sensor.mock_title_tank_pressure-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.mock_title_tank_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tank pressure', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank pressure', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_pressure', + 'unique_id': '00000000-0000-0000-0000-000000000004-98bd0102-0b0e-421a-84e5-ddbf75dc6de4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pressure_tank][sensor.mock_title_tank_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'pressure', + : 'Mock Title Tank pressure', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_tank_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.312', + }) +# --- +# name: test_sensors[pressure_tank][sensor.mock_title_water_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.mock_title_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '00000000-0000-0000-0000-000000000004-98bd010e-0b0e-421a-84e5-ddbf75dc6de4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pressure_tank][sensor.mock_title_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Mock Title Water temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21', + }) +# --- # name: test_sensors[timer][sensor.mock_title_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 58bc06170546..f6bf6727fd12 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -10,6 +10,7 @@ from gardena_bluetooth.const import ( Battery, EventHistory, FlowStatistics, + Pump, Sensor, Spray, Valve, @@ -23,7 +24,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import AQUA_CONTOUR_SERVICE_INFO, WATER_TIMER_SERVICE_INFO, setup_entry +from . import ( + AQUA_CONTOUR_SERVICE_INFO, + PRESSURE_TANK_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, + setup_entry, +) from tests.common import MockConfigEntry, snapshot_platform @@ -106,6 +112,14 @@ async def test_setup( }, id="aqua_contour", ), + pytest.param( + PRESSURE_TANK_SERVICE_INFO, + { + Pump.tank_preassure.uuid: Pump.tank_preassure.encode(3312), + Pump.water_temperature.uuid: Pump.water_temperature.encode(21), + }, + id="pressure_tank", + ), ], ) async def test_sensors( diff --git a/tests/components/gatus/__init__.py b/tests/components/gatus/__init__.py new file mode 100644 index 000000000000..26e3d9d4d9b1 --- /dev/null +++ b/tests/components/gatus/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Gatus integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Gatus integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/gatus/conftest.py b/tests/components/gatus/conftest.py new file mode 100644 index 000000000000..1e557575e259 --- /dev/null +++ b/tests/components/gatus/conftest.py @@ -0,0 +1,58 @@ +"""Common fixtures for the Gatus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from gatus_api import EndpointStatus, Result +import pytest + +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gatus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_gatus_client() -> Generator[AsyncMock]: + """Mock the third-party Gatus API client wrapper globally across coordinator and config flow.""" + with ( + patch( + "homeassistant.components.gatus.coordinator.GatusClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.gatus.config_flow.GatusClient", + new=mock_client, + ), + ): + client_instance = mock_client.return_value + client_instance.get_endpoints_statuses = AsyncMock( + return_value=[ + EndpointStatus( + key="backend_service", + name="Backend Service", + group="Core", + results=[Result(success=True, status=200)], + ) + ] + ) + yield client_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture to cleanly create a Gatus configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example.com:8080"}, + entry_id="1234567890abcdef1234567890abcdef", + ) diff --git a/tests/components/gatus/fixtures/group.json b/tests/components/gatus/fixtures/group.json new file mode 100644 index 000000000000..8c7c032441e2 --- /dev/null +++ b/tests/components/gatus/fixtures/group.json @@ -0,0 +1,8 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "group": "Core", + "results": [{ "success": false, "status": 500 }] + } +] diff --git a/tests/components/gatus/fixtures/no_group.json b/tests/components/gatus/fixtures/no_group.json new file mode 100644 index 000000000000..c582a4eb7535 --- /dev/null +++ b/tests/components/gatus/fixtures/no_group.json @@ -0,0 +1,7 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "results": [{ "success": true, "status": 200 }] + } +] diff --git a/tests/components/gatus/snapshots/test_binary_sensor.ambr b/tests/components/gatus/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..56d2fc37d30b --- /dev/null +++ b/tests/components/gatus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-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': None, + 'entity_id': 'binary_sensor.core_backend_service', + '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': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Core Backend Service', + }), + 'context': , + 'entity_id': 'binary_sensor.core_backend_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/gatus/snapshots/test_diagnostics.ambr b/tests/components/gatus/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..96507ed1cedc --- /dev/null +++ b/tests/components/gatus/snapshots/test_diagnostics.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': list([ + dict({ + 'group': 'Core', + 'key': 'backend_service', + 'name': 'Backend Service', + 'results': list([ + dict({ + 'status': 200, + 'success': True, + }), + ]), + }), + ]), + }) +# --- diff --git a/tests/components/gatus/test_binary_sensor.py b/tests/components/gatus/test_binary_sensor.py new file mode 100644 index 000000000000..761a2c757a0f --- /dev/null +++ b/tests/components/gatus/test_binary_sensor.py @@ -0,0 +1,164 @@ +"""Tests for the Gatus binary sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from gatus_api import EndpointStatus, GatusClientError, Result +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) + + +async def test_binary_sensor_setup_and_states( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +def _to_endpoint_statuses(raw_data: list[dict[str, Any]]) -> list[EndpointStatus]: + return [ + EndpointStatus( + key=ep["key"], + name=ep["name"], + group=ep.get("group"), + results=[ + Result(success=r["success"], status=r["status"]) + for r in ep.get("results", []) + ], + ) + for ep in raw_data + ] + + +async def test_binary_sensor_dynamic_update( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the binary sensor entity updates when the mock client returns new data.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_data = await async_load_json_array_fixture(hass, "gatus/group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + freezer.tick(300) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "off" + + +async def test_binary_sensor_no_group( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the binary sensor entity is created correctly when an endpoint has no group.""" + mock_data = await async_load_json_array_fixture(hass, "gatus/no_group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_client_error( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a client exception cleanly marks entities as unavailable.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError + + freezer.tick(30) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "unavailable" + + +async def test_binary_sensor_empty_results( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with empty results is treated as unavailable.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "unavailable" + + # Verify underlying properties return None directly on empty results + entity = hass.data["binary_sensor"].get_entity("binary_sensor.backend_service") + assert entity is not None + assert entity.latest_result is None + assert entity.is_on is None + + +async def test_binary_sensor_missing_status( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with a result missing a status code is handled correctly.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[Result(success=False, status=None)], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "off" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py new file mode 100644 index 000000000000..45d9bf8c1241 --- /dev/null +++ b/tests/components/gatus/test_config_flow.py @@ -0,0 +1,217 @@ +"""Test the Gatus Config flow.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant import config_entries +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form, validate the client, and create a successful entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success_with_path( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form, validate the client, and create a successful entry with a sub-path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080/gatus-instance/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080/gatus-instance", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test handling validation failures and ensuring the flow can completely recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate configurations for the same base URL abort early.""" + mock_config_entry.add_to_hass(hass) + 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_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + 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_URL: "http://gatus.example2.com:8080/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test reconfigure flow errors and recover.""" + 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" + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts if the new URL is already configured.""" + mock_config_entry.add_to_hass(hass) + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example3.com:8080"}, + entry_id="other_id", + ) + other_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_URL: "http://gatus.example3.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gatus/test_diagnostics.py b/tests/components/gatus/test_diagnostics.py new file mode 100644 index 000000000000..ae5a4023cfde --- /dev/null +++ b/tests/components/gatus/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Gatus diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for Gatus config entry.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/gatus/test_init.py b/tests/components/gatus/test_init.py new file mode 100644 index 000000000000..ebb0f2d772c3 --- /dev/null +++ b/tests/components/gatus/test_init.py @@ -0,0 +1,46 @@ +"""Tests for the Gatus integration setup and unload lifecycle.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant.components.gatus.coordinator import GatusDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_setup_and_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test standard successful setup and unload cycle of the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert isinstance(mock_config_entry.runtime_data, GatusDataUpdateCoordinator) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_failure_retry( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an API connection failure during initial setup places the entry in retry state.""" + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError( + "Cannot connect to Gatus API during initial setup" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 21c1561484aa..d89232e9365f 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -242,13 +242,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_hygrostat_config_entry.entry_id ) @@ -266,28 +259,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_hygrostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_hygrostat.async_unload_entry", wraps=generic_hygrostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_hygrostat_entity_entry = entity_registry.async_get( "humidifier.my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_hygrostat config entry is not in the device + # Check that the source device is not removed and the generic_hygrostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed @@ -541,7 +532,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_hygrostat_config_entry = MockConfigEntry( data={}, @@ -560,21 +551,12 @@ async def test_migration_1_1( ) generic_hygrostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 51e996c22c7c..5ed1c5a1d524 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -247,13 +247,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_thermostat_config_entry.entry_id ) @@ -271,28 +264,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_thermostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_thermostat.async_unload_entry", wraps=generic_thermostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_thermostat_entity_entry = entity_registry.async_get( "climate.my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_thermostat config entry is not in the device + # Check that the source device is not removed and the generic_thermostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed @@ -554,7 +545,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_thermostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_thermostat_config_entry = MockConfigEntry( data={}, @@ -573,21 +564,12 @@ async def test_migration_1_1( ) generic_thermostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_thermostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/gentex_homelink/snapshots/test_init.ambr b/tests/components/gentex_homelink/snapshots/test_init.ambr index d9d52e290bcc..7644cfae437a 100644 --- a/tests/components/gentex_homelink/snapshots/test_init.ambr +++ b/tests/components/gentex_homelink/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'TestDevice', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_drive/snapshots/test_sensor.ambr b/tests/components/google_drive/snapshots/test_sensor.ambr index 06df145129b9..870b016d6322 100644 --- a/tests/components/google_drive/snapshots/test_sensor.ambr +++ b/tests/components/google_drive/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensor.10 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://drive.google.com/drive/folders/HA folder ID', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'testuser@domain.com', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 63355f58df32..ff9dc38be179 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'Google AI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': None, 'name': 'Google AI STT', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': None, 'name': 'Google AI TTS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -112,7 +109,6 @@ 'model_id': None, 'name': 'Google AI Task', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 97861c9782ad..1aab55c30e42 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -755,7 +755,7 @@ async def test_migration_from_v1_with_same_keys( ( {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, [], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: # Wrong device registry, TTS subentry created @@ -770,7 +770,7 @@ async def test_migration_from_v1_with_same_keys( unique_id=None, ) ], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 # or later: Correct device registry, TTS subentry created diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py index 275936e3929e..783ad31df148 100644 --- a/tests/components/google_health/conftest.py +++ b/tests/components/google_health/conftest.py @@ -6,15 +6,19 @@ from typing import Any from unittest.mock import AsyncMock, patch from google_health_api.model import ( + BODY_FAT, DAILY_RESTING_HEART_RATE, WEIGHT, + ActiveEnergyBurnedRollupValue, DailyRollupDataPoint, DataPoint, DataType, DistanceRollupValue, + FloorsRollupValue, Identity, ListDataPointResult, StepsRollupValue, + TotalCaloriesRollupValue, UserInfo, _ListDataPointsModel, ) @@ -129,6 +133,20 @@ def mock_google_health_client() -> Generator[AsyncMock]: client.distance.today.return_value = _rollup_fixture( "distance.json", DistanceRollupValue, "distance" ) + client.active_energy_burned = AsyncMock() + client.active_energy_burned.today.return_value = _rollup_fixture( + "active_energy_burned.json", + ActiveEnergyBurnedRollupValue, + "activeEnergyBurned", + ) + client.total_calories = AsyncMock() + client.total_calories.today.return_value = _rollup_fixture( + "total_calories.json", TotalCaloriesRollupValue, "totalCalories" + ) + client.floors = AsyncMock() + client.floors.today.return_value = _rollup_fixture( + "floors.json", FloorsRollupValue, "floors" + ) client.weight = AsyncMock() client.weight.list.return_value = _list_fixture("weight.json", WEIGHT) client.weight.required_read_scopes = [ @@ -138,6 +156,8 @@ def mock_google_health_client() -> Generator[AsyncMock]: client.daily_resting_heart_rate.list.return_value = _list_fixture( "resting_heart_rate.json", DAILY_RESTING_HEART_RATE ) + client.body_fat = AsyncMock() + client.body_fat.list.return_value = _list_fixture("body_fat.json", BODY_FAT) client.get_identity.return_value = Identity.from_dict( load_json_object_fixture("identity.json", DOMAIN) ) diff --git a/tests/components/google_health/fixtures/active_energy_burned.json b/tests/components/google_health/fixtures/active_energy_burned.json new file mode 100644 index 000000000000..f8365250bfe5 --- /dev/null +++ b/tests/components/google_health/fixtures/active_energy_burned.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "activeEnergyBurned": { + "kcalSum": 350.5 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/body_fat.json b/tests/components/google_health/fixtures/body_fat.json new file mode 100644 index 000000000000..76b35c2e197d --- /dev/null +++ b/tests/components/google_health/fixtures/body_fat.json @@ -0,0 +1,12 @@ +{ + "dataPoints": [ + { + "bodyFat": { + "percentage": 18.5, + "sampleTime": { + "physicalTime": "2026-06-29T00:00:00Z" + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/floors.json b/tests/components/google_health/fixtures/floors.json new file mode 100644 index 000000000000..c507b4c97b10 --- /dev/null +++ b/tests/components/google_health/fixtures/floors.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "floors": { + "countSum": 5 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/total_calories.json b/tests/components/google_health/fixtures/total_calories.json new file mode 100644 index 000000000000..f78675d39388 --- /dev/null +++ b/tests/components/google_health/fixtures/total_calories.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "totalCalories": { + "kcalSum": 2100.2 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/snapshots/test_sensor.ambr b/tests/components/google_health/snapshots/test_sensor.ambr index b1973b10e0ae..6dbd4ffb0cdb 100644 --- a/tests/components/google_health/snapshots/test_sensor.ambr +++ b/tests/components/google_health/snapshots/test_sensor.ambr @@ -1,4 +1,112 @@ # serializer version: 1 +# name: test_all_entities[sensor.google_health_active_calories-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.google_health_active_calories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active calories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active calories', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_calories', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_active_calories', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_active_calories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Active calories', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_active_calories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '350.5', + }) +# --- +# name: test_all_entities[sensor.google_health_body_fat-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.google_health_body_fat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Body fat', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Body fat', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'body_fat', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_body_fat', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.google_health_body_fat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Body fat', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.google_health_body_fat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- # name: test_all_entities[sensor.google_health_distance-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -57,6 +165,59 @@ 'state': '5000.0', }) # --- +# name: test_all_entities[sensor.google_health_floors-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.google_health_floors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floors', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floors', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_floors', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.google_health_floors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Floors', + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_floors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_all_entities[sensor.google_health_resting_heart_rate-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -165,6 +326,60 @@ 'state': '10500', }) # --- +# name: test_all_entities[sensor.google_health_total_calories-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.google_health_total_calories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total calories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total calories', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_calories', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_total_calories', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_total_calories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Total calories', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_total_calories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2100.2', + }) +# --- # name: test_all_entities[sensor.google_health_weight-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/google_health/test_init.py b/tests/components/google_health/test_init.py index ec66763deebd..766d26d1c209 100644 --- a/tests/components/google_health/test_init.py +++ b/tests/components/google_health/test_init.py @@ -1,5 +1,6 @@ """Tests for Google Health integration lifecycle (init/unloading).""" +import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -111,9 +112,13 @@ async def test_setup_missing_activity_scope( assert hass.states.get("sensor.google_health_steps") is None assert hass.states.get("sensor.google_health_distance") is None + assert hass.states.get("sensor.google_health_active_calories") is None + assert hass.states.get("sensor.google_health_total_calories") is None + assert hass.states.get("sensor.google_health_floors") is None assert hass.states.get("sensor.google_health_weight") is not None assert hass.states.get("sensor.google_health_resting_heart_rate") is not None + assert hass.states.get("sensor.google_health_body_fat") is not None @pytest.mark.usefixtures("mock_google_health_client") @@ -137,9 +142,13 @@ async def test_setup_missing_measurements_scope( assert hass.states.get("sensor.google_health_weight") is None assert hass.states.get("sensor.google_health_resting_heart_rate") is None + assert hass.states.get("sensor.google_health_body_fat") is None assert hass.states.get("sensor.google_health_steps") is not None assert hass.states.get("sensor.google_health_distance") is not None + assert hass.states.get("sensor.google_health_active_calories") is not None + assert hass.states.get("sensor.google_health_total_calories") is not None + assert hass.states.get("sensor.google_health_floors") is not None async def test_setup_oauth_implementation_unavailable( @@ -182,6 +191,9 @@ async def test_runtime_auth_error( dt_util.utcnow() + POLLING_INTERVAL + timedelta(seconds=1), ) await hass.async_block_till_done() + # Yield to let untracked asyncio.gather tasks run + await asyncio.sleep(0) + await hass.async_block_till_done() # Verify that the flow was initiated flows = hass.config_entries.flow.async_progress() diff --git a/tests/components/google_health/test_sensor.py b/tests/components/google_health/test_sensor.py index 9e3b9b0f9600..f14a8017c907 100644 --- a/tests/components/google_health/test_sensor.py +++ b/tests/components/google_health/test_sensor.py @@ -33,9 +33,12 @@ async def test_sensor_empty_rollup( mock_google_health_client: AsyncMock, integration_setup: Callable[[], Awaitable[bool]], ) -> None: - """Test steps and distance sensors when the rollup endpoint returns no data.""" + """Test rollup sensors when the rollup endpoints return no data.""" mock_google_health_client.steps.today.return_value = None mock_google_health_client.distance.today.return_value = None + mock_google_health_client.active_energy_burned.today.return_value = None + mock_google_health_client.total_calories.today.return_value = None + mock_google_health_client.floors.today.return_value = None assert await integration_setup() @@ -46,3 +49,15 @@ async def test_sensor_empty_rollup( distance_state = hass.states.get("sensor.google_health_distance") assert distance_state is not None assert distance_state.state == "0.0" + + active_calories_state = hass.states.get("sensor.google_health_active_calories") + assert active_calories_state is not None + assert active_calories_state.state == "0.0" + + total_calories_state = hass.states.get("sensor.google_health_total_calories") + assert total_calories_state is not None + assert total_calories_state.state == "0.0" + + floors_state = hass.states.get("sensor.google_health_floors") + assert floors_state is not None + assert floors_state.state == "0" diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index 23e5f540594b..e8d461c582bd 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -9,7 +9,7 @@ from google.protobuf import duration_pb2 from google.type import localized_text_pb2 import pytest -from homeassistant.components.google_travel_time.const import DOMAIN +from homeassistant.components.google_travel_time.const import DEFAULT_NAME, DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -22,6 +22,7 @@ async def mock_config_fixture( """Mock a Google Travel Time config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, + title=DEFAULT_NAME, data=data, options=options, entry_id="test", diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 460bb63b0d56..a6c2db43f605 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -29,7 +29,7 @@ from homeassistant.components.google_travel_time.const import ( UNITS_IMPERIAL, ) from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -63,6 +63,7 @@ async def assert_common_reconfigure_steps( await hass.async_block_till_done() entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.title == DEFAULT_NAME assert entry.data == RECONFIGURE_CONFIG @@ -77,7 +78,6 @@ async def assert_common_create_steps( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == { - CONF_NAME: DEFAULT_NAME, CONF_API_KEY: "api_key", CONF_ORIGIN: "location1", CONF_DESTINATION: "49.983862755708444,8.223882827079068", diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 1d9e92004a7e..f405a924cf03 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -74,6 +74,25 @@ async def test_sensor(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("routes_mock") +async def test_sensor_name_from_entry_title(hass: HomeAssistant) -> None: + """Test that the sensor name is taken from the config entry title.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home to work", + data=MOCK_CONFIG, + options=DEFAULT_OPTIONS, + entry_id="test", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.google_travel_time_home_to_work")) + assert state.name == "Google Travel Time Home to work" + assert state.state == "27.0" + + @pytest.mark.usefixtures("mock_update_empty", "mock_config") @pytest.mark.parametrize( ("data", "options"), diff --git a/tests/components/growatt_server/snapshots/test_init.ambr b/tests/components/growatt_server/snapshots/test_init.ambr index b1e65ceefadf..4aa7050670b8 100644 --- a/tests/components/growatt_server/snapshots/test_init.ambr +++ b/tests/components/growatt_server/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_classic_api_setup DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'TLX123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TLX123456', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'MIN123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'MIN123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/harbor/__init__.py b/tests/components/harbor/__init__.py new file mode 100644 index 000000000000..592e9ed1a213 --- /dev/null +++ b/tests/components/harbor/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Harbor integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Harbor integration in Home Assistant.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/harbor/conftest.py b/tests/components/harbor/conftest.py new file mode 100644 index 000000000000..09b59dc3d250 --- /dev/null +++ b/tests/components/harbor/conftest.py @@ -0,0 +1,118 @@ +"""Common fixtures for the Harbor tests.""" + +from collections.abc import Awaitable, Callable, Generator +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + +SERIAL = "1234567890" +CERT_PEM = "-----BEGIN CERTIFICATE-----\nMIIBdummy\n-----END CERTIFICATE-----" +KEY_PEM = "-----BEGIN PRIVATE KEY-----\nMIIBdummy\n-----END PRIVATE KEY-----" + +HEARTBEAT_TOPIC = f"cameras/{SERIAL}/events/heartbeat" +LIVEKIT_TOPIC = f"cameras/{SERIAL}/events/local_livekit_heartbeat" + +HEARTBEAT_PAYLOAD: dict[str, Any] = { + "temperature": 98.6, + "os_version": "1.2.3", + "settings": {"preference_display_name": "Nursery"}, +} +LIVEKIT_PAYLOAD: dict[str, Any] = { + "bitrate": 1234.5, + "network_bars": 3, + "stream_quality": "GOOD", + "viewers_by_identity_full": { + "viewer-1": {"identity": "alice"}, + "viewer-2": {"identity": "bob"}, + }, + "os_version": "1.2.3", + "app_version": "4.5.6", +} + + +def connection_callback( + mock_mqtt_client: AsyncMock, +) -> Callable[[bool], Awaitable[None]]: + """Return the on_connection_change callback the integration registered.""" + return mock_mqtt_client.call_args.kwargs["on_connection_change"] + + +async def emit_message( + mock_mqtt_client: AsyncMock, topic: str, payload: dict[str, Any] +) -> None: + """Deliver an MQTT message through the handler the integration registered.""" + await mock_mqtt_client.call_args.kwargs["message_handler"](topic, payload) + + +async def set_connected(mock_mqtt_client: AsyncMock, connected: bool) -> None: + """Drive the MQTT connection state the integration observes.""" + await connection_callback(mock_mqtt_client)(connected) + + +@pytest.fixture(autouse=True) +def mock_connect_timeout() -> Generator[None]: + """Patch the connect timeout so unreachable-camera tests run quickly.""" + with patch("homeassistant.components.harbor.coordinator.CONNECT_TIMEOUT", 0): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.harbor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mqtt_client() -> Generator[AsyncMock]: + """Mock the Harbor MQTT client, reporting a successful connection on start.""" + with patch( + "homeassistant.components.harbor.coordinator.HarborMQTTClient", + autospec=True, + ) as mock_client: + + async def _start() -> None: + await set_connected(mock_client, True) + # Setup waits for the first device message too; simulate the + # initial-commands response landing right after connect, the + # same way a real camera answers before any explicit test + # message. Empty so it doesn't set values tests don't expect. + await mock_client.call_args.kwargs["message_handler"](HEARTBEAT_TOPIC, {}) + + mock_client.return_value.start.side_effect = _start + # The config flow probes get-settings for the camera's friendly name; + # default to an unnamed camera so the title falls back to the serial. + mock_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name=None) + ) + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock Harbor config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=SERIAL, + title=f"Camera {SERIAL}", + data={ + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) diff --git a/tests/components/harbor/snapshots/test_init.ambr b/tests/components/harbor/snapshots/test_init.ambr new file mode 100644 index 000000000000..c28770ed0675 --- /dev/null +++ b/tests/components/harbor/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'harbor', + '1234567890', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Harbor', + 'model': 'Harbor Camera', + 'model_id': None, + 'name': 'Nursery', + 'name_by_user': None, + 'serial_number': '1234567890', + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/harbor/snapshots/test_sensor.ambr b/tests/components/harbor/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..b24e5b1e26de --- /dev/null +++ b/tests/components/harbor/snapshots/test_sensor.ambr @@ -0,0 +1,289 @@ +# serializer version: 1 +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-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': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bitrate', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bitrate', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bitrate', + 'unique_id': '1234567890_bitrate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'data_rate', + : 'Harbor Camera 1234567890 Bitrate', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.5', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream quality', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Stream quality', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stream_quality', + 'unique_id': '1234567890_stream_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Harbor Camera 1234567890 Stream quality', + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_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.harbor_camera_1234567890_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Harbor Camera 1234567890 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-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.harbor_camera_1234567890_viewers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Viewers', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Viewers', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'num_viewers', + 'unique_id': '1234567890_num_viewers', + 'unit_of_measurement': 'viewers', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Viewers', + : , + : 'viewers', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-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': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': '1234567890_wifi_strength', + 'unit_of_measurement': 'bars', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Wi-Fi strength', + : , + : 'bars', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/harbor/test_config_flow.py b/tests/components/harbor/test_config_flow.py new file mode 100644 index 000000000000..238d2a662749 --- /dev/null +++ b/tests/components/harbor/test_config_flow.py @@ -0,0 +1,231 @@ +"""Test the Harbor config flow.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import CERT_PEM, KEY_PEM, SERIAL, set_connected + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_mqtt_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the full user flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Camera {SERIAL}" + assert result["result"].unique_id == SERIAL + assert result["data"] == { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + } + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + assert client_id.startswith(f"{DOMAIN}-{SERIAL}-probe-") + assert client_id != f"{DOMAIN}-{SERIAL}-probe" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_uses_friendly_name( + hass: HomeAssistant, mock_mqtt_client: AsyncMock +) -> None: + """Test the entry is titled with the camera's friendly name when set.""" + mock_mqtt_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name="Nursery") + ) + + 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_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nursery" + + +@pytest.mark.parametrize( + ("user_input", "error_field", "error"), + [ + ( + { + CONF_SERIAL: "123", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: "abcdefghij", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: "not a cert", + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_CERT_PEM, + "invalid_cert", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: "not a key", + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_KEY_PEM, + "invalid_key", + ), + ], + ids=["short_serial", "non_digit_serial", "bad_cert", "bad_key"], +) +@pytest.mark.usefixtures("mock_mqtt_client", "mock_setup_entry") +async def test_user_flow_validation_errors( + hass: HomeAssistant, + user_input: dict[str, str], + error_field: str, + error: str, +) -> None: + """Test validation errors are surfaced and recoverable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {error_field: error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow aborts when the serial is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the flow shows an error and recovers when the camera is unreachable.""" + # Start the probe client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + 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_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # A subsequent connection succeeds and the entry is created. + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/harbor/test_init.py b/tests/components/harbor/test_init.py new file mode 100644 index 000000000000..ef0d69148f2a --- /dev/null +++ b/tests/components/harbor/test_init.py @@ -0,0 +1,144 @@ +"""Test the Harbor integration setup and coordinator.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.harbor.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + SERIAL, + emit_message, + set_connected, +) + +from tests.common import MockConfigEntry + +# The default test fixture reports no device data on connect, so the device +# keeps its placeholder name and the entity id derives from that. +_SENSOR = "sensor.harbor_camera_1234567890_temperature" + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a config entry loads, starts the client, and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_mqtt_client.return_value.start.called + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_uses_instance_scoped_mqtt_client_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup uses an MQTT client id unique to this HA instance.""" + await setup_integration(hass, mock_config_entry) + + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + + assert client_id.startswith(f"{DOMAIN}-") + assert client_id.endswith(f"-{SERIAL}") + assert client_id != f"{DOMAIN}-{SERIAL}" + + +async def test_setup_retry_when_unreachable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera never connects.""" + # Start the client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_retry_when_no_data_arrives( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera connects but never sends data.""" + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_availability_follows_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test entity availability tracks the MQTT connection.""" + await setup_integration(hass, mock_config_entry) + + # Setup waits for the first device message, so entities start available. + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # A repeated connected signal is a no-op and keeps entities available. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # Losing the connection flips entities back to unavailable. + await set_connected(mock_mqtt_client, False) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state == STATE_UNAVAILABLE + + # Reconnecting restores availability without needing fresh device data. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the device adopts the name and firmware from the first message. + + Setup waits for that first message before registering entities, so the + device is correct from the start instead of needing a later reload. + """ + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device == snapshot diff --git a/tests/components/harbor/test_sensor.py b/tests/components/harbor/test_sensor.py new file mode 100644 index 000000000000..79502050034a --- /dev/null +++ b/tests/components/harbor/test_sensor.py @@ -0,0 +1,93 @@ +"""Test the Harbor sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + LIVEKIT_PAYLOAD, + LIVEKIT_TOPIC, + emit_message, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Harbor sensors report their values.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_missing_values_are_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test sensors without a value in the payload report unknown.""" + await setup_integration(hass, mock_config_entry) + + # Only the heartbeat arrives; sensors fed by the LiveKit message stay unknown. + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.harbor_camera_1234567890_temperature").state == "37.0" + ) + assert ( + hass.states.get("sensor.harbor_camera_1234567890_bitrate").state + == STATE_UNKNOWN + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_unexpected_enum_value_stays_valid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a stream quality outside the declared options surfaces as unknown. + + The library maps unrecognized enum values onto its own "unknown" member; + the sensor treats that as no value rather than exposing "unknown" as a + literal enum option. + """ + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.harbor_camera_1234567890_stream_quality" + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == "good" + + # The camera reports a stream quality outside the known set. + await emit_message( + mock_mqtt_client, + LIVEKIT_TOPIC, + {**LIVEKIT_PAYLOAD, "stream_quality": "DEGRADED"}, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index df4700467bce..3b666f9e430f 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -375,6 +375,56 @@ async def test_websocket_non_admin_user( assert msg["error"]["message"] == "Unauthorized" +async def test_websocket_store_reload_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.""" + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=False, + version_latest="2.0.0", + ) + ] + 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() + + assert hass.states.get("update.test_update").state == "off" + + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=True, + 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", + } + ) + msg = await websocket_client.receive_json() + assert msg["success"] + + assert hass.states.get("update.test_update").state == "on" + supervisor_client.store.reload.assert_not_called() + + async def test_update_addon( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 58685f5cf8f4..e0dad7c41b70 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -259,6 +259,7 @@ dict({ 'device': dict({ 'area_id': None, + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), diff --git a/tests/components/here_travel_time/const.py b/tests/components/here_travel_time/const.py index 167fd51dc5bb..6c84806c453a 100644 --- a/tests/components/here_travel_time/const.py +++ b/tests/components/here_travel_time/const.py @@ -7,7 +7,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LONGITUDE, TRAVEL_MODE_CAR, ) -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_MODE API_KEY = "test" @@ -23,5 +23,4 @@ DEFAULT_CONFIG = { CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_CAR, - CONF_NAME: "test", } diff --git a/tests/components/here_travel_time/test_config_flow.py b/tests/components/here_travel_time/test_config_flow.py index 82c75471896f..51032088dd1b 100644 --- a/tests/components/here_travel_time/test_config_flow.py +++ b/tests/components/here_travel_time/test_config_flow.py @@ -21,13 +21,14 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, CONF_TRAFFIC_MODE, + DEFAULT_NAME, DOMAIN, ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, TRAVEL_MODE_CAR, TRAVEL_MODE_PUBLIC, ) -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_MODE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -66,7 +67,6 @@ async def user_step_result_fixture( { CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_CAR, - CONF_NAME: "test", }, ) await hass.async_block_till_done() @@ -88,7 +88,6 @@ async def option_init_result_fixture( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_PUBLIC, - CONF_NAME: "test", }, version=HERETravelTimeConfigFlow.VERSION, minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, @@ -144,7 +143,6 @@ async def test_step_user(hass: HomeAssistant, menu_options) -> None: { CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_CAR, - CONF_NAME: "test", }, ) await hass.async_block_till_done() @@ -215,8 +213,8 @@ async def test_step_destination_coordinates( ) assert location_selector_result["type"] is FlowResultType.CREATE_ENTRY entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.title == DEFAULT_NAME assert entry.data == { - CONF_NAME: "test", CONF_API_KEY: API_KEY, CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), CONF_ORIGIN_LONGITUDE: float(ORIGIN_LONGITUDE), @@ -243,8 +241,8 @@ async def test_step_destination_entity( ) assert entity_selector_result["type"] is FlowResultType.CREATE_ENTRY entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.title == DEFAULT_NAME assert entry.data == { - CONF_NAME: "test", CONF_API_KEY: API_KEY, CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), CONF_ORIGIN_LONGITUDE: float(ORIGIN_LONGITUDE), @@ -275,8 +273,8 @@ async def test_reconfigure_destination_entity(hass: HomeAssistant) -> None: assert destination_entity_selector_result["type"] is FlowResultType.ABORT assert destination_entity_selector_result["reason"] == "reconfigure_successful" entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.title == "Mock Title" assert entry.data == { - CONF_NAME: "test", CONF_API_KEY: API_KEY, CONF_ORIGIN_ENTITY_ID: "zone.home", CONF_DESTINATION_ENTITY_ID: "zone.home", @@ -307,8 +305,8 @@ async def test_reconfigure_destination_coordinates(hass: HomeAssistant) -> None: assert destination_entity_selector_result["type"] is FlowResultType.ABORT assert destination_entity_selector_result["reason"] == "reconfigure_successful" entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.title == "Mock Title" assert entry.data == { - CONF_NAME: "test", CONF_API_KEY: API_KEY, CONF_ORIGIN_ENTITY_ID: "zone.home", CONF_DESTINATION_LATITUDE: 43.0, @@ -341,7 +339,6 @@ async def do_common_reconfiguration_steps(hass: HomeAssistant) -> None: { CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_BICYCLE, - CONF_NAME: "test", }, ) await hass.async_block_till_done() @@ -369,7 +366,6 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: { CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_CAR, - CONF_NAME: "test", }, ) @@ -392,7 +388,6 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: { CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_CAR, - CONF_NAME: "test", }, ) diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 9aba4a3ea33b..7ab6bcbf1a0c 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -64,7 +64,6 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_API_KEY, CONF_MODE, - CONF_NAME, EVENT_HOMEASSISTANT_STARTED, UnitOfLength, UnitOfTime, @@ -135,6 +134,7 @@ async def test_sensor( hass.set_state(CoreState.not_running) entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -143,7 +143,6 @@ async def test_sensor( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: mode, - CONF_NAME: "test", }, options={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, @@ -200,6 +199,7 @@ async def test_circular_ref( hass.states.async_set("test.second", "test.first") entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_ENTITY_ID: "test.first", @@ -207,7 +207,6 @@ async def test_circular_ref( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -229,6 +228,7 @@ async def test_public_transport(hass: HomeAssistant) -> None: hass.set_state(CoreState.not_running) entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -237,7 +237,6 @@ async def test_public_transport(hass: HomeAssistant) -> None: CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_PUBLIC, - CONF_NAME: "test", }, options={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, @@ -268,6 +267,7 @@ async def test_no_attribution_response(hass: HomeAssistant) -> None: """Test that no_attribution is handled.""" entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -276,7 +276,6 @@ async def test_no_attribution_response(hass: HomeAssistant) -> None: CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_PUBLIC, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -319,13 +318,13 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non ) entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_ENTITY_ID: "zone.origin", CONF_DESTINATION_ENTITY_ID: "device_tracker.test", CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -360,6 +359,7 @@ async def test_destination_entity_not_found( """Test that a not existing destination_entity_id is caught.""" entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -367,7 +367,6 @@ async def test_destination_entity_not_found( CONF_DESTINATION_ENTITY_ID: "device_tracker.test", CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -390,6 +389,7 @@ async def test_origin_entity_not_found( """Test that a not existing origin_entity_id is caught.""" entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_ENTITY_ID: "device_tracker.test", @@ -397,7 +397,6 @@ async def test_origin_entity_not_found( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -424,6 +423,7 @@ async def test_invalid_destination_entity_state( ) entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -431,7 +431,6 @@ async def test_invalid_destination_entity_state( CONF_DESTINATION_ENTITY_ID: "device_tracker.test", CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -460,6 +459,7 @@ async def test_invalid_origin_entity_state( ) entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_ENTITY_ID: "device_tracker.test", @@ -467,7 +467,6 @@ async def test_invalid_origin_entity_state( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -497,6 +496,7 @@ async def test_route_not_found( ): entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -505,7 +505,6 @@ async def test_route_not_found( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_TRUCK, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -622,6 +621,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: # create and add entry mock_entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id=DOMAIN, data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, @@ -684,6 +684,7 @@ async def test_transit_errors( ): entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -692,7 +693,6 @@ async def test_transit_errors( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_PUBLIC, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -720,6 +720,7 @@ async def test_routing_rate_limit( ): entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, @@ -771,6 +772,7 @@ async def test_transit_rate_limit( ): entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -779,7 +781,6 @@ async def test_transit_rate_limit( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_PUBLIC, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, @@ -825,6 +826,7 @@ async def test_multiple_sections( hass.set_state(CoreState.not_running) entry = MockConfigEntry( domain=DOMAIN, + title="test", unique_id="0123456789", data={ CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), @@ -833,7 +835,6 @@ async def test_multiple_sections( CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), CONF_API_KEY: API_KEY, CONF_MODE: TRAVEL_MODE_BICYCLE, - CONF_NAME: "test", }, options=DEFAULT_OPTIONS, version=HERETravelTimeConfigFlow.VERSION, diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index f2618a385a4e..f0736fad5ae9 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -173,18 +173,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, history_stats_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test config entry is removed when the source entity is removed.""" assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() @@ -196,15 +188,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.history_stats.async_unload_entry", wraps=history_stats.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -212,8 +201,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_history_stats") - # Check that the history_stats config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is removed @@ -383,7 +373,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes history_stats config entry from device.""" + """Test migration from v1.1 keeps the history_stats entity linked to the source device.""" history_stats_config_entry = MockConfigEntry( data={}, @@ -402,21 +392,12 @@ async def test_migration_1_1( ) history_stats_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() assert history_stats_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # 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 diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr index 4e073efb62fc..9d4e9ce436b1 100644 --- a/tests/components/homee/snapshots/test_init.ambr +++ b/tests/components/homee/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_general_data DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'TestHomee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.2.3', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_general_data.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -59,7 +58,6 @@ 'model_id': None, 'name': 'Shutter with position and slats', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '4.54', 'via_device_id': , diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 7d0cc7666fb5..1cfba8296a31 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -4,8 +4,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -26,7 +26,6 @@ 'model_id': None, 'name': 'Airversa AP2 1808', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '0.8.16', 'via_device_id': None, @@ -653,8 +652,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +674,6 @@ 'model_id': None, 'name': 'eufy HomeBase2-0AAA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000A', 'sw_version': '2.1.6', 'via_device_id': None, @@ -731,8 +729,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -753,7 +751,6 @@ 'model_id': None, 'name': 'eufyCam2-0000', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000D', 'sw_version': '1.6.7', 'via_device_id': , @@ -993,8 +990,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1015,7 +1012,6 @@ 'model_id': None, 'name': 'eufyCam2-000A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000B', 'sw_version': '1.6.7', 'via_device_id': , @@ -1255,8 +1251,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1277,7 +1273,6 @@ 'model_id': None, 'name': 'eufyCam2-000A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000C', 'sw_version': '1.6.7', 'via_device_id': , @@ -1521,8 +1516,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1543,7 +1538,6 @@ 'model_id': None, 'name': 'Aqara-Hub-E1-00A0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00aa00000a0', 'sw_version': '3.3.0', 'via_device_id': None, @@ -1744,8 +1738,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1766,7 +1760,6 @@ 'model_id': None, 'name': 'Contact Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '158d0007c59c6a', 'sw_version': '0', 'via_device_id': , @@ -1921,8 +1914,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1943,7 +1936,6 @@ 'model_id': None, 'name': 'Aqara Hub-1563', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0000000123456789', 'sw_version': '1.4.7', 'via_device_id': None, @@ -2212,8 +2204,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2234,7 +2226,6 @@ 'model_id': None, 'name': 'Programmable Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111a1111a1a111', 'sw_version': '9', 'via_device_id': None, @@ -2344,8 +2335,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2366,7 +2357,6 @@ 'model_id': None, 'name': 'ArloBabyA0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00A0000000000', 'sw_version': '1.10.931', 'via_device_id': None, @@ -2867,8 +2857,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2889,7 +2879,6 @@ 'model_id': None, 'name': 'InWall Outlet-0394DE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1020301376', 'sw_version': '1.0.0', 'via_device_id': None, @@ -3351,8 +3340,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3373,7 +3362,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB3C', 'sw_version': '1.0.0', 'via_device_id': , @@ -3526,8 +3514,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3548,7 +3536,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -4020,8 +4007,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4042,7 +4029,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB1C', 'sw_version': '1.0.0', 'via_device_id': , @@ -4195,8 +4181,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4217,7 +4203,6 @@ 'model_id': None, 'name': 'Porch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB2C', 'sw_version': '1.0.0', 'via_device_id': , @@ -4374,8 +4359,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4396,7 +4381,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -4644,8 +4628,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4666,7 +4650,6 @@ 'model_id': None, 'name': 'Basement Window 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -4907,8 +4890,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4929,7 +4912,6 @@ 'model_id': None, 'name': 'Deck Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5170,8 +5152,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5192,7 +5174,6 @@ 'model_id': None, 'name': 'Front Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5433,8 +5414,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5455,7 +5436,6 @@ 'model_id': None, 'name': 'Garage Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5696,8 +5676,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5718,7 +5698,6 @@ 'model_id': None, 'name': 'Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5966,8 +5945,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5988,7 +5967,6 @@ 'model_id': None, 'name': 'Living Room Window 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6229,8 +6207,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6251,7 +6229,6 @@ 'model_id': None, 'name': 'Loft window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6492,8 +6469,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6514,7 +6491,6 @@ 'model_id': None, 'name': 'Master BR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6762,8 +6738,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6784,7 +6760,6 @@ 'model_id': None, 'name': 'Master BR Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7025,8 +7000,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7047,7 +7022,6 @@ 'model_id': None, 'name': 'Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '4.8.70226', 'via_device_id': None, @@ -7433,8 +7407,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7455,7 +7429,6 @@ 'model_id': None, 'name': 'Upstairs BR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7703,8 +7676,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7725,7 +7698,6 @@ 'model_id': None, 'name': 'Upstairs BR Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7970,8 +7942,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7992,7 +7964,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -8468,8 +8439,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8490,7 +8461,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB3C', 'sw_version': '1.0.0', 'via_device_id': , @@ -8591,8 +8561,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8613,7 +8583,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -8896,8 +8865,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8918,7 +8887,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB1C', 'sw_version': '1.0.0', 'via_device_id': , @@ -9071,8 +9039,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9093,7 +9061,6 @@ 'model_id': None, 'name': 'Porch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB2C', 'sw_version': '1.0.0', 'via_device_id': , @@ -9250,8 +9217,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9272,7 +9239,6 @@ 'model_id': None, 'name': 'My ecobee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789016', 'sw_version': '4.7.340214', 'via_device_id': None, @@ -9757,8 +9723,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9779,7 +9745,6 @@ 'model_id': None, 'name': 'Master Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '4.5.130201', 'via_device_id': None, @@ -10074,8 +10039,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10096,7 +10061,6 @@ 'model_id': None, 'name': 'Eve Degree AA11', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AA00A0A00000', 'sw_version': '1.2.8', 'via_device_id': None, @@ -10465,8 +10429,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10487,7 +10451,6 @@ 'model_id': None, 'name': 'Eve Energy 50FF', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AA00A0A00000', 'sw_version': '1.2.9', 'via_device_id': None, @@ -10844,8 +10807,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10866,7 +10829,6 @@ 'model_id': None, 'name': 'HAA-C718B3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'C718B3-1', 'sw_version': '5.0.18', 'via_device_id': None, @@ -11068,8 +11030,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11090,7 +11052,6 @@ 'model_id': None, 'name': 'HAA-C718B3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'C718B3-2', 'sw_version': '5.0.18', 'via_device_id': None, @@ -11194,8 +11155,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11216,7 +11177,6 @@ 'model_id': None, 'name': 'Family Room North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.family_door_north', 'sw_version': '3.6.2', 'via_device_id': , @@ -11369,8 +11329,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11391,7 +11351,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -11447,8 +11406,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11469,7 +11428,6 @@ 'model_id': None, 'name': 'Kitchen Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.kitchen_window', 'sw_version': '3.6.2', 'via_device_id': , @@ -11626,8 +11584,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11648,7 +11606,6 @@ 'model_id': None, 'name': 'Ceiling Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.ceiling_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -11757,8 +11714,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11779,7 +11736,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -11835,8 +11791,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11857,7 +11813,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -11971,8 +11926,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11993,7 +11948,6 @@ 'model_id': None, 'name': '89 Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'climate.89_living_room', 'sw_version': '2024.2.0', 'via_device_id': , @@ -12325,8 +12279,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12347,7 +12301,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12407,8 +12360,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12429,7 +12382,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12485,8 +12437,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12507,7 +12459,6 @@ 'model_id': None, 'name': 'Laundry Smoke ED78', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'light.laundry_smoke_ed78', 'sw_version': '1.4.84', 'via_device_id': , @@ -12671,8 +12622,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12693,7 +12644,6 @@ 'model_id': None, 'name': 'Family Room North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.family_door_north', 'sw_version': '3.6.2', 'via_device_id': , @@ -12846,8 +12796,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12868,7 +12818,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12924,8 +12873,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12946,7 +12895,6 @@ 'model_id': None, 'name': 'Kitchen Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.kitchen_window', 'sw_version': '3.6.2', 'via_device_id': , @@ -13103,8 +13051,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13125,7 +13073,6 @@ 'model_id': None, 'name': 'Ceiling Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.ceiling_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13234,8 +13181,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13256,7 +13203,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -13312,8 +13258,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13334,7 +13280,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13449,8 +13394,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13471,7 +13416,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -13527,8 +13471,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13549,7 +13493,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13664,8 +13607,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13686,7 +13629,6 @@ 'model_id': None, 'name': '89 Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'climate.89_living_room', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14027,8 +13969,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14049,7 +13991,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14109,8 +14050,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14131,7 +14072,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14187,8 +14127,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14209,7 +14149,6 @@ 'model_id': None, 'name': 'Humidifier 182A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'humidifier.humidifier_182a', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14380,8 +14319,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14402,7 +14341,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14458,8 +14396,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14480,7 +14418,6 @@ 'model_id': None, 'name': 'Humidifier 182A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'humidifier.humidifier_182a', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14651,8 +14588,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14673,7 +14610,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14729,8 +14665,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14751,7 +14687,6 @@ 'model_id': None, 'name': 'Laundry Smoke ED78', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'light.laundry_smoke_ed78', 'sw_version': '1.4.84', 'via_device_id': , @@ -14925,8 +14860,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14947,7 +14882,6 @@ 'model_id': None, 'name': 'Air Conditioner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00000001', 'sw_version': '1.0.0', 'via_device_id': None, @@ -15139,8 +15073,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15161,7 +15095,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462395276914', 'sw_version': '1.46.13', 'via_device_id': , @@ -15279,8 +15212,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15301,7 +15234,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462395276939', 'sw_version': '1.46.13', 'via_device_id': , @@ -15419,8 +15351,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15441,7 +15373,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462403113447', 'sw_version': '1.46.13', 'via_device_id': , @@ -15559,8 +15490,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15581,7 +15512,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462403233419', 'sw_version': '1.46.13', 'via_device_id': , @@ -15699,8 +15629,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15721,7 +15651,6 @@ 'model_id': None, 'name': 'Hue ambiance spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462412411853', 'sw_version': '1.46.13', 'via_device_id': , @@ -15849,8 +15778,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15871,7 +15800,6 @@ 'model_id': None, 'name': 'Hue ambiance spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462412413293', 'sw_version': '1.46.13', 'via_device_id': , @@ -15999,8 +15927,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16021,7 +15949,6 @@ 'model_id': None, 'name': 'Hue dimmer switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462389072572', 'sw_version': '45.1.17846', 'via_device_id': , @@ -16339,8 +16266,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16361,7 +16288,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462378982941', 'sw_version': '1.46.13', 'via_device_id': , @@ -16471,8 +16397,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16493,7 +16419,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462378983942', 'sw_version': '1.46.13', 'via_device_id': , @@ -16603,8 +16528,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16625,7 +16550,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462379122122', 'sw_version': '1.46.13', 'via_device_id': , @@ -16735,8 +16659,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16757,7 +16681,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462379123707', 'sw_version': '1.46.13', 'via_device_id': , @@ -16867,8 +16790,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16889,7 +16812,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462383114163', 'sw_version': '1.46.13', 'via_device_id': , @@ -16999,8 +16921,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17021,7 +16943,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462383114193', 'sw_version': '1.46.13', 'via_device_id': , @@ -17131,8 +17052,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17153,7 +17074,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462385996792', 'sw_version': '1.46.13', 'via_device_id': , @@ -17263,8 +17183,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17285,7 +17205,6 @@ 'model_id': None, 'name': 'Philips hue - 482544', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': '1.32.1932126170', 'via_device_id': None, @@ -17345,8 +17264,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17367,7 +17286,6 @@ 'model_id': None, 'name': 'Koogeek-LS1-20833F', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAA011111111111', 'sw_version': '2.2.15', 'via_device_id': None, @@ -17491,8 +17409,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17513,7 +17431,6 @@ 'model_id': None, 'name': 'Koogeek-P1-A00AA0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'EUCP03190xxxxx48', 'sw_version': '2.3.7', 'via_device_id': None, @@ -17670,8 +17587,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17692,7 +17609,6 @@ 'model_id': None, 'name': 'Koogeek-SW2-187A91', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CNNT061751001372', 'sw_version': '1.0.3', 'via_device_id': None, @@ -17892,8 +17808,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17914,7 +17830,6 @@ 'model_id': None, 'name': 'Lennox', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXXXXXX', 'sw_version': '3.40.XX', 'via_device_id': None, @@ -18196,8 +18111,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18218,7 +18133,6 @@ 'model_id': None, 'name': 'LG webOS TV AF80', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '999AAAAAA999', 'sw_version': '04.71.04', 'via_device_id': None, @@ -18388,8 +18302,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18410,7 +18324,6 @@ 'model_id': None, 'name': 'Caséta® Wireless Fan Speed Control', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '39024290', 'sw_version': '001.005', 'via_device_id': , @@ -18519,8 +18432,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18541,7 +18454,6 @@ 'model_id': None, 'name': 'Smart Bridge 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12344331', 'sw_version': '08.08', 'via_device_id': None, @@ -18601,8 +18513,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18623,7 +18535,6 @@ 'model_id': None, 'name': 'MSS425F-15cc', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'HH41234', 'sw_version': '4.2.3', 'via_device_id': None, @@ -18903,8 +18814,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18925,7 +18836,6 @@ 'model_id': None, 'name': 'MSS565-28da', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'BB1121', 'sw_version': '4.1.9', 'via_device_id': None, @@ -19039,8 +18949,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19061,7 +18971,6 @@ 'model_id': None, 'name': 'Mysa-85dda9', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAA000', 'sw_version': '2.8.1', 'via_device_id': None, @@ -19395,8 +19304,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19417,7 +19326,6 @@ 'model_id': None, 'name': 'Nanoleaf Strip 3B32', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAA011111111111', 'sw_version': '1.4.40', 'via_device_id': None, @@ -19677,8 +19585,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19699,7 +19607,6 @@ 'model_id': None, 'name': 'Netatmo-Doorbell-g738658', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'g738658', 'sw_version': '80.0.0', 'via_device_id': None, @@ -19994,8 +19901,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20016,7 +19923,6 @@ 'model_id': None, 'name': 'Smart CO Alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '1.0.3', 'via_device_id': None, @@ -20166,8 +20072,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20188,7 +20094,6 @@ 'model_id': None, 'name': 'Healthy Home Coach', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAAAAAAAA', 'sw_version': '59', 'via_device_id': None, @@ -20498,8 +20403,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20520,7 +20425,6 @@ 'model_id': None, 'name': 'RainMachine-00ce4a', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00aa0000aa0a', 'sw_version': '1.0.4', 'via_device_id': None, @@ -20956,8 +20860,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20978,7 +20882,6 @@ 'model_id': None, 'name': 'Master Bath South', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21131,8 +21034,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21153,7 +21056,6 @@ 'model_id': None, 'name': 'RYSE SmartBridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0101.3521.0436', 'sw_version': '1.3.0', 'via_device_id': None, @@ -21209,8 +21111,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21231,7 +21133,6 @@ 'model_id': None, 'name': 'RYSE SmartShade', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '', 'sw_version': '', 'via_device_id': , @@ -21388,8 +21289,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21410,7 +21311,6 @@ 'model_id': None, 'name': 'BR Left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21563,8 +21463,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21585,7 +21485,6 @@ 'model_id': None, 'name': 'LR Left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21738,8 +21637,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21760,7 +21659,6 @@ 'model_id': None, 'name': 'LR Right', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21913,8 +21811,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21935,7 +21833,6 @@ 'model_id': None, 'name': 'RYSE SmartBridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0401.3521.0679', 'sw_version': '1.3.0', 'via_device_id': None, @@ -21991,8 +21888,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22013,7 +21910,6 @@ 'model_id': None, 'name': 'RZSS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -22170,8 +22066,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22192,7 +22088,6 @@ 'model_id': None, 'name': 'SENSE ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAA000', 'sw_version': '004.027.000', 'via_device_id': None, @@ -22297,8 +22192,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22319,7 +22214,6 @@ 'model_id': None, 'name': 'SIMPLEconnect Fan-06F674', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890abcd', 'sw_version': '', 'via_device_id': None, @@ -22487,8 +22381,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22509,7 +22403,6 @@ 'model_id': None, 'name': 'VELUX Internal Cover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '0.0.0', 'via_device_id': None, @@ -22617,8 +22510,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22639,7 +22532,6 @@ 'model_id': None, 'name': 'U by Moen-015F44', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '3.3.0', 'via_device_id': None, @@ -23042,8 +22934,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23064,7 +22956,6 @@ 'model_id': None, 'name': 'VELUX Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '16.0.0', 'via_device_id': None, @@ -23274,8 +23165,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23296,7 +23187,6 @@ 'model_id': None, 'name': 'VELUX Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1a11a1', 'sw_version': '70', 'via_device_id': None, @@ -23352,8 +23242,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23374,7 +23264,6 @@ 'model_id': None, 'name': 'VELUX Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a11b111', 'sw_version': '16', 'via_device_id': , @@ -23580,8 +23469,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23602,7 +23491,6 @@ 'model_id': None, 'name': 'VELUX Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1111111a114a111a', 'sw_version': '48', 'via_device_id': , @@ -23710,8 +23598,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23732,7 +23620,6 @@ 'model_id': None, 'name': 'VELUX Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '0.0.0', 'via_device_id': None, @@ -23840,8 +23727,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23862,7 +23749,6 @@ 'model_id': None, 'name': 'VELUX External Cover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '15.0.0', 'via_device_id': None, @@ -23969,8 +23855,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23991,7 +23877,6 @@ 'model_id': None, 'name': 'VOCOlinc-Flowerbud-0d324b', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AM01121849000327', 'sw_version': '3.121.2', 'via_device_id': None, @@ -24289,8 +24174,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24311,7 +24196,6 @@ 'model_id': None, 'name': 'VOCOlinc-VP3-123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'EU0121203xxxxx07', 'sw_version': '1.101.2', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index d1c4c93824cf..6d717631cbe2 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_identify_button.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 856ca1f32b34..e06105aa381d 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -62,8 +62,8 @@ # name: test_number_entities[HWE-SKT-11].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -88,7 +88,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -157,8 +156,8 @@ # name: test_number_entities[HWE-SKT-21].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -183,7 +182,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index 2fd3327220ff..7c1138e33c03 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -63,8 +63,8 @@ # name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -89,7 +89,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index cdd4f676608e..5c45661005f3 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_battery_cycles:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -90,8 +89,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -116,7 +115,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -183,8 +181,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -209,7 +207,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -276,8 +273,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -302,7 +299,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -369,8 +365,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -395,7 +391,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -462,8 +457,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -488,7 +483,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -555,8 +549,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -581,7 +575,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -648,8 +641,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_state_of_charge:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -674,7 +667,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -741,8 +733,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_uptime:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -767,7 +759,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -827,8 +818,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -853,7 +844,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -920,8 +910,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_rssi:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -946,7 +936,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -1009,8 +998,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1035,7 +1024,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -1094,8 +1082,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1120,7 +1108,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1187,8 +1174,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1213,7 +1200,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1280,8 +1266,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1306,7 +1292,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1373,8 +1358,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1399,7 +1384,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1466,8 +1450,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1492,7 +1476,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1559,8 +1542,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1585,7 +1568,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1652,8 +1634,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1678,7 +1660,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1742,8 +1723,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1768,7 +1749,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1835,8 +1815,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1861,7 +1841,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1928,8 +1907,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1954,7 +1933,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2021,8 +1999,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2047,7 +2025,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2106,8 +2083,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2132,7 +2109,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2195,8 +2171,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2221,7 +2197,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2288,8 +2263,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2314,7 +2289,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2381,8 +2355,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2407,7 +2381,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2474,8 +2447,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2500,7 +2473,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2567,8 +2539,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2593,7 +2565,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2660,8 +2631,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2686,7 +2657,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2753,8 +2723,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2779,7 +2749,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2846,8 +2815,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2872,7 +2841,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2939,8 +2907,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2965,7 +2933,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3032,8 +2999,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3058,7 +3025,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3125,8 +3091,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3151,7 +3117,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3218,8 +3183,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3244,7 +3209,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3311,8 +3275,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3337,7 +3301,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3401,8 +3364,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3427,7 +3390,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3491,8 +3453,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3517,7 +3479,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3581,8 +3542,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3607,7 +3568,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3674,8 +3634,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3700,7 +3660,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3767,8 +3726,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3793,7 +3752,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3860,8 +3818,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3886,7 +3844,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3953,8 +3910,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3979,7 +3936,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4046,8 +4002,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4072,7 +4028,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4139,8 +4094,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4165,7 +4120,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4232,8 +4186,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4258,7 +4212,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4325,8 +4278,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4351,7 +4304,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4418,8 +4370,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4444,7 +4396,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4511,8 +4462,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4537,7 +4488,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4604,8 +4554,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4630,7 +4580,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4689,8 +4638,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4715,7 +4664,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4778,8 +4726,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4804,7 +4752,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -4868,8 +4815,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_battery_group_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4894,7 +4841,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -4961,8 +4907,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_battery_group_target_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4987,7 +4933,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5054,8 +4999,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5080,7 +5025,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5147,8 +5091,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5173,7 +5117,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5240,8 +5183,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5266,7 +5209,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5333,8 +5275,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5359,7 +5301,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5418,8 +5359,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5444,7 +5385,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5511,8 +5451,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5537,7 +5477,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5604,8 +5543,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5630,7 +5569,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5697,8 +5635,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5723,7 +5661,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5790,8 +5727,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5816,7 +5753,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5883,8 +5819,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5909,7 +5845,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5976,8 +5911,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6002,7 +5937,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6069,8 +6003,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6095,7 +6029,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6162,8 +6095,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6188,7 +6121,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6255,8 +6187,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6281,7 +6213,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6348,8 +6279,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6374,7 +6305,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6441,8 +6371,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6467,7 +6397,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6526,8 +6455,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6552,7 +6481,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6616,8 +6544,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6642,7 +6570,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6709,8 +6636,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6735,7 +6662,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6794,8 +6720,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6820,7 +6746,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6887,8 +6812,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6913,7 +6838,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6980,8 +6904,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7006,7 +6930,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7073,8 +6996,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7099,7 +7022,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7158,8 +7080,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7184,7 +7106,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7243,8 +7164,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7269,7 +7190,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7342,8 +7262,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7368,7 +7288,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7435,8 +7354,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7461,7 +7380,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7528,8 +7446,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7554,7 +7472,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7621,8 +7538,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7647,7 +7564,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7714,8 +7630,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7740,7 +7656,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7799,8 +7714,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7825,7 +7740,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7884,8 +7798,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7910,7 +7824,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7969,8 +7882,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7995,7 +7908,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8054,8 +7966,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8080,7 +7992,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8139,8 +8050,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8165,7 +8076,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8224,8 +8134,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8250,7 +8160,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8317,8 +8226,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8343,7 +8252,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8402,8 +8310,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8428,7 +8336,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8491,8 +8398,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8513,7 +8420,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_G001', 'sw_version': None, 'via_device_id': , @@ -8580,8 +8486,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8602,7 +8508,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_H001', 'sw_version': None, 'via_device_id': , @@ -8669,8 +8574,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8691,7 +8596,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', 'sw_version': None, 'via_device_id': , @@ -8754,8 +8658,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8776,7 +8680,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', 'sw_version': None, 'via_device_id': , @@ -8843,8 +8746,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8865,7 +8768,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_W001', 'sw_version': None, 'via_device_id': , @@ -8932,8 +8834,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8958,7 +8860,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9022,8 +8923,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9048,7 +8949,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9115,8 +9015,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9141,7 +9041,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9208,8 +9107,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9234,7 +9133,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9301,8 +9199,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9327,7 +9225,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9386,8 +9283,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9412,7 +9309,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9479,8 +9375,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9505,7 +9401,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9572,8 +9467,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9598,7 +9493,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9665,8 +9559,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9691,7 +9585,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9758,8 +9651,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9784,7 +9677,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9851,8 +9743,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9877,7 +9769,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9944,8 +9835,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9970,7 +9861,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10037,8 +9927,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10063,7 +9953,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10130,8 +10019,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10156,7 +10045,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10223,8 +10111,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10249,7 +10137,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10316,8 +10203,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10342,7 +10229,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10409,8 +10295,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10435,7 +10321,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10494,8 +10379,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10520,7 +10405,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10584,8 +10468,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10610,7 +10494,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10677,8 +10560,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10703,7 +10586,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10762,8 +10644,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10788,7 +10670,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10855,8 +10736,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10881,7 +10762,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10948,8 +10828,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10974,7 +10854,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11041,8 +10920,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11067,7 +10946,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11126,8 +11004,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11152,7 +11030,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11211,8 +11088,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11237,7 +11114,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11310,8 +11186,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11336,7 +11212,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11403,8 +11278,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11429,7 +11304,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11496,8 +11370,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11522,7 +11396,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11589,8 +11462,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11615,7 +11488,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11682,8 +11554,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11708,7 +11580,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11767,8 +11638,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11793,7 +11664,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11852,8 +11722,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11878,7 +11748,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11937,8 +11806,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11963,7 +11832,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12022,8 +11890,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12048,7 +11916,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12107,8 +11974,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12133,7 +12000,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12192,8 +12058,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12218,7 +12084,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12285,8 +12150,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12311,7 +12176,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12370,8 +12234,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12396,7 +12260,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12459,8 +12322,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12481,7 +12344,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12548,8 +12410,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12570,7 +12432,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12637,8 +12498,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12659,7 +12520,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12722,8 +12582,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12744,7 +12604,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12811,8 +12670,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12833,7 +12692,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12900,8 +12758,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12926,7 +12784,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12990,8 +12847,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_battery_group_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13016,7 +12873,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13083,8 +12939,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_battery_group_target_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13109,7 +12965,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13176,8 +13031,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13202,7 +13057,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13269,8 +13123,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13295,7 +13149,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13362,8 +13215,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13388,7 +13241,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13455,8 +13307,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13481,7 +13333,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13540,8 +13391,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13566,7 +13417,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13633,8 +13483,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13659,7 +13509,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13726,8 +13575,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13752,7 +13601,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13819,8 +13667,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13845,7 +13693,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13912,8 +13759,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13938,7 +13785,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14005,8 +13851,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14031,7 +13877,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14098,8 +13943,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14124,7 +13969,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14191,8 +14035,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14217,7 +14061,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14284,8 +14127,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14310,7 +14153,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14377,8 +14219,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14403,7 +14245,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14470,8 +14311,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14496,7 +14337,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14563,8 +14403,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14589,7 +14429,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14648,8 +14487,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14674,7 +14513,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14738,8 +14576,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14764,7 +14602,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14831,8 +14668,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14857,7 +14694,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14916,8 +14752,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14942,7 +14778,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15009,8 +14844,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15035,7 +14870,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15102,8 +14936,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15128,7 +14962,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15195,8 +15028,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15221,7 +15054,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15280,8 +15112,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15306,7 +15138,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15365,8 +15196,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15391,7 +15222,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15464,8 +15294,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15490,7 +15320,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15557,8 +15386,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15583,7 +15412,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15650,8 +15478,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15676,7 +15504,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15743,8 +15570,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15769,7 +15596,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15836,8 +15662,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15862,7 +15688,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15921,8 +15746,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15947,7 +15772,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16006,8 +15830,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16032,7 +15856,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16091,8 +15914,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16117,7 +15940,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16176,8 +15998,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16202,7 +16024,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16261,8 +16082,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16287,7 +16108,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16346,8 +16166,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16372,7 +16192,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16439,8 +16258,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16465,7 +16284,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16524,8 +16342,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16550,7 +16368,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16613,8 +16430,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16635,7 +16452,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_G001', 'sw_version': None, 'via_device_id': , @@ -16702,8 +16518,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16724,7 +16540,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_H001', 'sw_version': None, 'via_device_id': , @@ -16791,8 +16606,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16813,7 +16628,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', 'sw_version': None, 'via_device_id': , @@ -16876,8 +16690,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16898,7 +16712,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', 'sw_version': None, 'via_device_id': , @@ -16965,8 +16778,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16987,7 +16800,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_W001', 'sw_version': None, 'via_device_id': , @@ -17054,8 +16866,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17080,7 +16892,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17144,8 +16955,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17170,7 +16981,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17237,8 +17047,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17263,7 +17073,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17330,8 +17139,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17356,7 +17165,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17423,8 +17231,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17449,7 +17257,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17516,8 +17323,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17542,7 +17349,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17609,8 +17415,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17635,7 +17441,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17702,8 +17507,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17728,7 +17533,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17795,8 +17599,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17821,7 +17625,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17888,8 +17691,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17914,7 +17717,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17981,8 +17783,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18007,7 +17809,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18074,8 +17875,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18100,7 +17901,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18167,8 +17967,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18193,7 +17993,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18260,8 +18059,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18286,7 +18085,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18353,8 +18151,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18379,7 +18177,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18446,8 +18243,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18472,7 +18269,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18531,8 +18327,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18557,7 +18353,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18624,8 +18419,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18650,7 +18445,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18709,8 +18503,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18735,7 +18529,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18802,8 +18595,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18828,7 +18621,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18895,8 +18687,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18921,7 +18713,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18988,8 +18779,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19014,7 +18805,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19081,8 +18871,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19107,7 +18897,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19174,8 +18963,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19200,7 +18989,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19267,8 +19055,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19293,7 +19081,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19360,8 +19147,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19386,7 +19173,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19445,8 +19231,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19471,7 +19257,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19530,8 +19315,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19556,7 +19341,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19615,8 +19399,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19641,7 +19425,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19700,8 +19483,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19726,7 +19509,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19785,8 +19567,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19811,7 +19593,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19870,8 +19651,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19896,7 +19677,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19963,8 +19743,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19989,7 +19769,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -20048,8 +19827,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20074,7 +19853,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -20137,8 +19915,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20163,7 +19941,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20230,8 +20007,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20256,7 +20033,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20323,8 +20099,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20349,7 +20125,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20416,8 +20191,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20442,7 +20217,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20509,8 +20283,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20535,7 +20309,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20602,8 +20375,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20628,7 +20401,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20687,8 +20459,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20713,7 +20485,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20776,8 +20547,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20802,7 +20573,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -20869,8 +20639,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20895,7 +20665,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -20962,8 +20731,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20988,7 +20757,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21055,8 +20823,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21081,7 +20849,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21148,8 +20915,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21174,7 +20941,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21241,8 +21007,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21267,7 +21033,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21334,8 +21099,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21360,7 +21125,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21424,8 +21188,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21450,7 +21214,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21517,8 +21280,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21543,7 +21306,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21610,8 +21372,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21636,7 +21398,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21703,8 +21464,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21729,7 +21490,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21796,8 +21556,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21822,7 +21582,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21881,8 +21640,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21907,7 +21666,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21970,8 +21728,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21996,7 +21754,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22063,8 +21820,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22089,7 +21846,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22156,8 +21912,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22182,7 +21938,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22241,8 +21996,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22267,7 +22022,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22330,8 +22084,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22356,7 +22110,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22423,8 +22176,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22449,7 +22202,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22516,8 +22268,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22542,7 +22294,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22609,8 +22360,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22635,7 +22386,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22702,8 +22452,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22728,7 +22478,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22795,8 +22544,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22821,7 +22570,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22888,8 +22636,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22914,7 +22662,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22978,8 +22725,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23004,7 +22751,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23071,8 +22817,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23097,7 +22843,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23164,8 +22909,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23190,7 +22935,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23257,8 +23001,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23283,7 +23027,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23342,8 +23085,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23368,7 +23111,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23431,8 +23173,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23457,7 +23199,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23524,8 +23265,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23550,7 +23291,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23617,8 +23357,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23643,7 +23383,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23710,8 +23449,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23736,7 +23475,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23803,8 +23541,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23829,7 +23567,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23896,8 +23633,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23922,7 +23659,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23989,8 +23725,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24015,7 +23751,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24082,8 +23817,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24108,7 +23843,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24175,8 +23909,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24201,7 +23935,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24268,8 +24001,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24294,7 +24027,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24361,8 +24093,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24387,7 +24119,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24454,8 +24185,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24480,7 +24211,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24547,8 +24277,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24573,7 +24303,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24637,8 +24366,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24663,7 +24392,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24727,8 +24455,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24753,7 +24481,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24817,8 +24544,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24843,7 +24570,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24910,8 +24636,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24936,7 +24662,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25003,8 +24728,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25029,7 +24754,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25096,8 +24820,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25122,7 +24846,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25189,8 +24912,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25215,7 +24938,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25282,8 +25004,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25308,7 +25030,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25375,8 +25096,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25401,7 +25122,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25468,8 +25188,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25494,7 +25214,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25561,8 +25280,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25587,7 +25306,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25654,8 +25372,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25680,7 +25398,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25747,8 +25464,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25773,7 +25490,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25840,8 +25556,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25866,7 +25582,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25925,8 +25640,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25951,7 +25666,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index d5d2a7ed1ebe..eb2a6c0e5483 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -78,7 +78,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -137,8 +136,8 @@ # name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +162,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -223,8 +221,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -249,7 +247,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -308,8 +305,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -334,7 +331,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -393,8 +389,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -419,7 +415,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -479,8 +474,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -505,7 +500,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -564,8 +558,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -590,7 +584,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -649,8 +642,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -675,7 +668,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -734,8 +726,8 @@ # name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -760,7 +752,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -819,8 +810,8 @@ # name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -845,7 +836,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -904,8 +894,8 @@ # name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -930,7 +920,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index ac24876413d7..1d18a527e0c9 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -196,7 +196,10 @@ async def test_remove_stale_device( assert len(device_entries) == 2 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) - assert any( + # Identifiers are unique per config entry, so Honeywell and OtherDomain have + # separate devices for 7654321; Honeywell's devices do not carry the OtherDomain + # identifier + assert not any( ("OtherDomain", 7654321) in device.identifiers for device in device_entries ) assert len(device_entries_other) == 1 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index a765f3a322b9..bf5dcd6c0c5f 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,13 +1,16 @@ """The tests for the Home Assistant HTTP component.""" import asyncio -from collections.abc import Callable +from collections.abc import Callable, Generator +import errno from http import HTTPStatus import logging import os from pathlib import Path +import socket +import ssl from typing import Any -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -20,11 +23,13 @@ from homeassistant.components.http.config import ( _DEFAULT_CONFIG, AUTO_REVERT_DELAY, HTTP_STORAGE_SCHEMA, + async_get_and_load_store, default_server_port, ) from homeassistant.components.http.const import ENV_SETUP_PORT -from homeassistant.const import HASSIO_USER_NAME +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError @@ -49,6 +54,49 @@ def disable_http_server(socket_enabled: None) -> None: return +# The unpatched original, for tests that exercise the real implementation. +_REAL_CREATE_SERVER = http.HomeAssistantHTTP._async_create_server + + +async def _ephemeral_server(hass: HomeAssistant) -> asyncio.Server: + """Create a bound but not serving server on an ephemeral localhost port.""" + return await hass.loop.create_server( + asyncio.Protocol, "127.0.0.1", 0, start_serving=False + ) + + +@pytest.fixture(autouse=True) +def mock_create_server() -> Generator[Mock]: + """Bind an ephemeral localhost server instead of the configured address. + + Binding the configured address for real would make parallel tests collide + on ports; an ephemeral localhost server keeps the serving path real. + """ + servers: list[asyncio.Server] = [] + + async def _bind_ephemeral(self: http.HomeAssistantHTTP) -> asyncio.Server: + server = await self.hass.loop.create_server( + self._make_protocol, + "127.0.0.1", + 0, + ssl=self.context, + start_serving=False, + ) + servers.append(server) + return server + + with patch( + "homeassistant.components.http.HomeAssistantHTTP._async_create_server", + autospec=True, + side_effect=_bind_ephemeral, + ) as mock_create: + yield mock_create + + # Close any server that is not already closed (closing twice is a no-op). + for server in servers: + server.close() + + def _setup_broken_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path]: test_dir = tmp_path / "test_broken_ssl" test_dir.mkdir() @@ -400,25 +448,30 @@ async def test_emergency_ssl_certificate_when_invalid( " certificate was not usable" in caplog.text ) - assert hass.http.site is not None + assert hass.http._server is not None async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: - """Test an emergency cert is only used in recovery mode.""" + """Test an emergency cert is only used in recovery mode. + + A broken SSL config in the stable slot fails setup (activating recovery + mode on a real boot); only recovery mode uses the emergency certificate. + """ cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - - assert ( - await async_setup_component( - hass, DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}} - ) - is False + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} ) + assert await async_setup_component(hass, DOMAIN, {}) is False + async def test_emergency_ssl_certificate_when_invalid_get_url_fails( hass: HomeAssistant, @@ -452,7 +505,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( " certificate was not usable" in caplog.text ) - assert hass.http.site is not None + assert hass.http._server is not None async def test_invalid_ssl_and_cannot_create_emergency_cert( @@ -480,7 +533,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( assert "Could not create an emergency self signed ssl certificate" in caplog.text assert len(mock_builder.mock_calls) == 1 - assert hass.http.site is not None + assert hass.http._server is not None async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( @@ -495,6 +548,9 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( an emergency cert (probably will never happen since this means the system is very broken), we do not want to startup http as it would allow connections that are not verified by the cert. + This intentionally overrides the recovery-mode fallback to the default + config: connections must never be accepted without client certificate + verification once it is configured. """ cert_path, key_path = await hass.async_add_executor_job( @@ -519,6 +575,68 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( assert len(mock_builder.mock_calls) == 1 +async def test_emergency_ssl_certificate_enforces_peer_certificate( + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], +) -> None: + """Test the emergency cert still enforces client certificate verification. + + When the configured SSL certificate is broken and recovery mode falls + back to the emergency self-signed certificate, a configured peer + certificate must still be applied - connections must never be accepted + without client certificate verification once it is configured. + """ + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmp_path + ) + hass_storage[DOMAIN] = _stable_http_storage( + { + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + "ssl_peer_certificate": str(cert_path), + } + ) + hass.config.recovery_mode = True + + with patch("ssl.SSLContext.load_verify_locations") as mock_load_verify: + assert await async_setup_component(hass, DOMAIN, {}) is True + + assert "emergency self signed ssl certificate" in caplog.text + mock_load_verify.assert_called_once_with(str(cert_path)) + assert hass.http.context is not None + assert hass.http.context.verify_mode is ssl.CERT_REQUIRED + + +async def test_create_server_passes_configuration(hass: HomeAssistant) -> None: + """The real server factory passes the configured values to asyncio.""" + server = http.HomeAssistantHTTP( + hass, + server_host=["127.0.0.1", "::1"], + server_port=1234, + ssl_certificate=None, + ssl_peer_certificate=None, + ssl_key=None, + trusted_proxies=[], + ssl_profile=http.SSL_MODERN, + ) + + with patch.object( + hass.loop, "create_server", new=AsyncMock(return_value=Mock()) + ) as mock_create: + await _REAL_CREATE_SERVER(server) + + mock_create.assert_called_once_with( + server._make_protocol, + ["127.0.0.1", "::1"], + 1234, + ssl=None, + backlog=128, + start_serving=False, + ) + + async def test_cors_defaults(hass: HomeAssistant) -> None: """Test the CORS default settings.""" with patch("homeassistant.components.http.setup_cors") as mock_setup: @@ -742,15 +860,10 @@ async def test_server_host( expected_serverhost: list, expected_issues: set[tuple[str, str]], caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, ) -> None: """Test server_host behavior.""" - mock_server = Mock() - with ( - patch("homeassistant.components.http.is_hassio", return_value=hassio), - patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server, - ): + with patch("homeassistant.components.http.is_hassio", return_value=hassio): assert await async_setup_component( hass, DOMAIN, @@ -759,15 +872,9 @@ async def test_server_host( await hass.async_start() await hass.async_block_till_done() - mock_create_server.assert_called_once_with( - ANY, - expected_serverhost, - 8123, - ssl=None, - backlog=128, - reuse_address=None, - reuse_port=None, - ) + mock_create_server.assert_called_once() + assert hass.http.server_host == expected_serverhost + assert hass.http.server_port == 8123 assert set(issue_registry.issues) == expected_issues @@ -787,7 +894,6 @@ async def test_unix_socket_started_with_supervisor( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False ), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), patch( "homeassistant.components.http.web_runner.HomeAssistantUnixSite" "._create_unix_socket", @@ -812,7 +918,6 @@ async def test_unix_socket_not_started_without_supervisor( """Test unix socket is not started when not running under Supervisor.""" with ( patch.dict(os.environ, {}, clear=False), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) assert await async_setup_component(hass, DOMAIN, {"http": {}}) @@ -833,7 +938,6 @@ async def test_unix_socket_rejected_relative_path( {"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"}, clear=False, ), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): assert await async_setup_component(hass, DOMAIN, {"http": {}}) await hass.async_start() @@ -861,10 +965,9 @@ async def test_yaml_migration_to_storage( "trusted_proxies": ["127.0.0.0/8"], "ip_ban_enabled": False, } - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") assert issue is not None @@ -918,10 +1021,9 @@ async def test_yaml_migration_matches_stable_no_pending( "trusted_proxies": ["127.0.0.0/8"], "ip_ban_enabled": False, } - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() stored = hass_storage[DOMAIN]["data"] assert stored["pending"] is None @@ -956,10 +1058,9 @@ async def test_yaml_migration_differs_from_stable_creates_pending( } yaml_conf = {"server_port": 8765, "ip_ban_enabled": False} - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() stored = hass_storage[DOMAIN]["data"] assert stored["stable"] == existing_stable @@ -984,7 +1085,6 @@ async def test_yaml_migration_failure_creates_error_issue( yaml_conf = {"server_port": 9123} with ( - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), patch( "homeassistant.components.http.config.HTTPConfigStore.async_migrate_yaml", side_effect=RuntimeError("boom"), @@ -1012,17 +1112,12 @@ async def test_yaml_still_present_after_migration_creates_issue( ) yaml_conf = {"server_port": 1234} - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() # YAML must be ignored once migration is done; stable wins. - args, _ = mock_create_server.call_args - assert args[2] == 9876 + assert hass.config.api.port == 9876 issue = issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") assert issue is not None @@ -1047,10 +1142,9 @@ async def test_yaml_still_present_issue_cleared_when_yaml_removed( translation_key="yaml_still_present_after_migration", ) - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() assert ( issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") @@ -1071,16 +1165,11 @@ async def test_setup_uses_stable_config_when_no_yaml( } ) - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 9876 + assert hass.config.api.port == 9876 assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None assert ( @@ -1097,16 +1186,11 @@ async def test_setup_prefers_pending_over_stable_in_normal_mode( {"server_port": 9876}, pending={"server_port": 9999} ) - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 9999 + assert hass.config.api.port == 9999 async def test_recovery_mode_falls_back_to_stable( @@ -1119,16 +1203,11 @@ async def test_recovery_mode_falls_back_to_stable( ) hass.config.recovery_mode = True - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 9876 + assert hass.config.api.port == 9876 async def test_recovery_mode_with_no_storage( @@ -1145,16 +1224,11 @@ async def test_recovery_mode_with_no_storage( assert "http" not in hass_storage hass.config.recovery_mode = True - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 8123 + assert hass.config.api.port == 8123 # Recovery mode must not trigger YAML migration side effects. assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None @@ -1175,19 +1249,12 @@ async def test_recovery_mode_ignores_yaml( ) hass.config.recovery_mode = True - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component( - hass, DOMAIN, {"http": {"server_port": 1234}} - ) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": {"server_port": 1234}}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args # YAML's port must NOT win: stable is the only source of truth in recovery. - assert args[2] == 5555 + assert hass.config.api.port == 5555 # The migration must not run in recovery mode, so its flag stays untouched # and no deprecation issue is created on this boot. assert hass_storage[DOMAIN]["data"]["yaml_migration_done"] is False @@ -1205,19 +1272,14 @@ async def test_setup_migrates_v1_storage_to_v2( "data": {"server_port": 9876}, } - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() # The migrated v1 store config is only used in recovery mode. Since this # test isn't running in recovery mode, the YAML migration runs on first # boot after store migration. With no YAML http config, the default config is migrated to the pending slot and used. Therefore we assert below the default port (8123) - args, _ = mock_create_server.call_args - assert args[2] == 8123 + assert hass.config.api.port == 8123 assert hass_storage[DOMAIN]["version"] == 2 data = hass_storage[DOMAIN]["data"] # The v1→v2 migration normalises the payload through the storage schema, @@ -1252,19 +1314,14 @@ async def test_setup_port_env_var_used_as_default( hass_storage: dict[str, Any], ) -> None: """Test SETUP_PORT is used as the default server port without YAML config.""" - mock_server = Mock() with ( patch.dict(os.environ, {ENV_SETUP_PORT: "80"}), - patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server, ): assert await async_setup_component(hass, "http", {}) await hass.async_start() await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 80 + assert hass.config.api.port == 80 assert hass_storage["http"]["data"]["pending"]["server_port"] == 80 @@ -1274,11 +1331,10 @@ async def test_websocket_http_config( hass_storage: dict[str, Any], ) -> None: """Test the http/config, configure and promote websocket commands.""" - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) @@ -1399,11 +1455,10 @@ async def test_pending_config_auto_reverts_to_stable( # The revert deadline is anchored to the (frozen) load time. revert_at = dt_util.utcnow() + AUTO_REVERT_DELAY - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) @@ -1432,6 +1487,335 @@ async def test_pending_config_auto_reverts_to_stable( assert len(restart_calls) == 1 +@pytest.mark.parametrize( + "bind_error", + [ + OSError(errno.EADDRINUSE, "Address already in use"), + PermissionError(errno.EACCES, "Permission denied"), + socket.gaierror(socket.EAI_NONAME, "Name or service not known"), + ], + ids=["address-in-use", "permission-denied", "unresolvable-host"], +) +async def test_pending_config_reverted_in_place_on_bind_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, + bind_error: OSError, +) -> None: + """A pending config that cannot be bound is reverted within the same start. + + The trial fails while the config is realized during setup, so the stable + config is applied in place - no restart, no waiting out the trial window. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 80} + ) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + stable_server = await _ephemeral_server(hass) + mock_create_server.side_effect = [bind_error, stable_server] + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # The pending config is dropped and this same start continues on stable. + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": None, + "yaml_migration_done": True, + } + assert hass.config.api is not None + assert hass.config.api.port == 9876 + # The second bind attempt was for the stable config. + assert mock_create_server.call_args_list[1].args[0].server_port == 9876 + # No restart is involved and no revert stays scheduled. + assert len(restart_calls) == 0 + store = await async_get_and_load_store(hass) + assert store.revert_deadline is None + assert "could not be applied, reverting" in caplog.text + assert "previous HTTP configuration has been restored (server port 9876)" in ( + caplog.text + ) + stable_server.close() + await stable_server.wait_closed() + + +async def test_pending_config_reverted_in_place_on_ssl_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """A pending config whose SSL certificate is unusable reverts in place.""" + stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})) + # Craft the raw storage payload: the schema validates that the SSL files + # exist when the config is set, but they can vanish before the next start. + pending = dict(HTTP_STORAGE_SCHEMA({"server_port": 9999})) + pending["ssl_certificate"] = "/nonexistent/cert.pem" + pending["ssl_key"] = "/nonexistent/key.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": pending, "yaml_migration_done": True}, + } + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"]["pending"] is None + assert hass.config.api is not None + assert hass.config.api.port == 9876 + assert hass.config.api.use_ssl is False + assert len(restart_calls) == 0 + + +async def test_pending_config_reverted_in_place_on_ssl_peer_cert_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A pending config whose SSL peer certificate is unusable reverts in place.""" + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})) + pending = dict( + HTTP_STORAGE_SCHEMA( + { + "server_port": 9999, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + } + ) + ) + # The peer certificate vanished after the config was stored. + pending["ssl_peer_certificate"] = "/nonexistent/peer.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": pending, "yaml_migration_done": True}, + } + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + with patch("ssl.SSLContext.load_cert_chain"): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"]["pending"] is None + assert hass.config.api is not None + assert hass.config.api.port == 9876 + assert hass.config.api.use_ssl is False + assert len(restart_calls) == 0 + + +async def test_stable_config_ssl_peer_cert_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A stable config whose SSL peer certificate is unusable fails setup. + + An unusable stable SSL configuration must fail setup, activating recovery + mode on a real boot. + """ + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + stable = dict( + HTTP_STORAGE_SCHEMA( + { + "server_port": 9876, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + } + ) + ) + stable["ssl_peer_certificate"] = "/nonexistent/peer.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": None, "yaml_migration_done": True}, + } + + with patch("ssl.SSLContext.load_cert_chain"): + assert await async_setup_component(hass, DOMAIN, {}) is False + + +async def test_bound_server_closed_on_stop_before_start( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """A bound server is closed on stop even if it never started serving. + + If setup fails after binding (or recovery mode tears Home Assistant down + before serving starts), the stop event must close the server so a + follow-up boot in the same process can bind the address again. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 9876}) + + server = await _ephemeral_server(hass) + mock_create_server.side_effect = [server] + + with patch.object( + http.HomeAssistantHTTP, + "async_initialize", + side_effect=HomeAssistantError("Setup failed after binding"), + ): + assert not await async_setup_component(hass, DOMAIN, {}) + + assert server.sockets + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert not server.sockets + + +async def test_stable_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """A stable config that cannot be bound fails setup. + + Failing setup activates recovery mode on a real boot, which retries with + the stable config and falls back to the default config, so Home Assistant + stays reachable. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert len(restart_calls) == 0 + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 80}), + "pending": None, + "yaml_migration_done": True, + } + + +async def test_pending_and_stable_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """Setup fails when the trialed pending and the stable config cannot bind. + + The pending config must already be cleared and persisted, so the recovery + boot and future normal starts use stable instead of re-trialing it. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 80} + ) + + mock_create_server.side_effect = [ + OSError(errno.EADDRINUSE, "Address already in use"), + OSError(errno.EADDRINUSE, "Address already in use"), + ] + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert hass_storage["http"]["data"]["pending"] is None + + +async def test_create_server_normalizes_unencodable_host( + hass: HomeAssistant, +) -> None: + """A host name the IDNA codec cannot encode raises OSError. + + create_server() raises UnicodeError (a ValueError) for such host names, + e.g. a label longer than 63 characters; it must be normalized to OSError + so the config fallback chain handles it like any other bind failure. + """ + server = http.HomeAssistantHTTP( + hass, + server_host=[f"{'x' * 64}.example"], + server_port=8123, + ssl_certificate=None, + ssl_peer_certificate=None, + ssl_key=None, + trusted_proxies=[], + ssl_profile=http.SSL_MODERN, + ) + with ( + patch.object( + hass.loop, + "create_server", + side_effect=UnicodeError( + "encoding with 'idna' codec failed (UnicodeError: label too long)" + ), + ), + pytest.raises(OSError, match="error while resolving host"), + ): + await _REAL_CREATE_SERVER(server) + + +async def test_recovery_mode_bind_failure_falls_back_to_default_config( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, +) -> None: + """In recovery mode an unbindable stable config falls back to defaults. + + Recovery mode is the last resort and must not fail setup again, so the + default config is applied in place to keep the recovery UI reachable. + The stable config is left untouched. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + hass.config.recovery_mode = True + + default_server = await _ephemeral_server(hass) + mock_create_server.side_effect = [ + OSError(errno.EADDRINUSE, "Address already in use"), + default_server, + ] + + assert await async_setup_component(hass, DOMAIN, {}) + + assert "falling back to the default configuration" in caplog.text + assert hass.config.api is not None + assert hass.config.api.port == default_server_port() + # The second bind attempt was for the default config. + assert mock_create_server.call_args_list[1].args[0].server_port == ( + default_server_port() + ) + assert hass_storage["http"]["data"]["stable"] == HTTP_STORAGE_SCHEMA( + {"server_port": 80} + ) + default_server.close() + await default_server.wait_closed() + + +async def test_recovery_mode_default_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, +) -> None: + """Setup fails in recovery mode when even the default config cannot bind. + + The fallback chain is exhausted; failing setup makes the failure visible + to the outside (e.g. the Supervisor rolls back a Core update whose API + does not come up). + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + hass.config.recovery_mode = True + + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert f"Failed to create HTTP server at port {default_server_port()}" in ( + caplog.text + ) + + async def test_pending_config_promote_cancels_revert( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1445,11 +1829,10 @@ async def test_pending_config_promote_cancels_revert( restart_calls = async_mock_service(hass, "homeassistant", "restart") - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) @@ -1497,11 +1880,10 @@ async def test_websocket_http_config_invalid( config: dict, ) -> None: """Test that an invalid HTTP config is rejected.""" - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 7e1759bb9347..53577e4734f0 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': 'garden', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': '450XH', 'name': 'Test Mower 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index 2e7369e8a6d4..c63142b9e13e 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup DeviceRegistryEntrySnapshot({ 'area_id': 'garden', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': '305', 'name': 'Husqvarna AutoMower', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/huum/snapshots/test_init.ambr b/tests/components/huum/snapshots/test_init.ambr index eed66315bc3e..64b5ad5bcf09 100644 --- a/tests/components/huum/snapshots/test_init.ambr +++ b/tests/components/huum/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Huum sauna', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ialarm/snapshots/test_init.ambr b/tests/components/ialarm/snapshots/test_init.ambr index f778c3e63300..18a007de83bc 100644 --- a/tests/components/ialarm/snapshots/test_init.ambr +++ b/tests/components/ialarm/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'iAlarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 902f1035877d..6f3823d71538 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -38,7 +38,7 @@ from tests.common import ( TEST_DOMAIN = "test" -TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) +TEST_COMMAND = NECCommand(address=0x04FB, command=0xF7, modulation=38000) async def test_get_entities_component_not_loaded(hass: HomeAssistant) -> None: diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 2bc95fad38d9..5b6ea05f7464 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -266,18 +266,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, integration_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() @@ -289,15 +281,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.integration.async_unload_entry", wraps=integration.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -306,6 +295,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d integration_entity_entry = entity_registry.async_get("sensor.my_integration") assert integration_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -471,7 +463,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes integration config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" integration_config_entry = MockConfigEntry( data={}, @@ -491,22 +483,13 @@ async def test_migration_1_1( ) integration_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=integration_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() assert integration_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # 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") diff --git a/tests/components/intelliclima/snapshots/test_fan.ambr b/tests/components/intelliclima/snapshots/test_fan.ambr index 5719c5fa1be4..040aeccbe847 100644 --- a/tests/components/intelliclima/snapshots/test_fan.ambr +++ b/tests/components/intelliclima/snapshots/test_fan.ambr @@ -2,8 +2,8 @@ # name: test_all_fan_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_select.ambr b/tests/components/intelliclima/snapshots/test_select.ambr index dd3924cad183..8d5ea5bf47ad 100644 --- a/tests/components/intelliclima/snapshots/test_select.ambr +++ b/tests/components/intelliclima/snapshots/test_select.ambr @@ -2,8 +2,8 @@ # name: test_all_select_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_sensor.ambr b/tests/components/intelliclima/snapshots/test_sensor.ambr index 15e65167bb08..b858a934dcd8 100644 --- a/tests/components/intelliclima/snapshots/test_sensor.ambr +++ b/tests/components/intelliclima/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_all_sensor_entities.6 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 752ee97cef86..870e7e022357 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -14,8 +14,8 @@ # name: test_devices_creaction_ok[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -36,7 +36,6 @@ 'model_id': None, 'name': '[TEST] Light switch 0 (TEST_SERIAL_0)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index 02076bf55970..7c6b1a4c1611 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Luxemburger Str. 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_registry.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Bahnhofsstr. 1A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index 5fb786029b4e..4ea9f6eeda74 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'mock model', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12345678', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 2d5045edcc99..9869b0e90276 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -61,7 +61,7 @@ async def test_user_discovery_success( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.55"} assert result["result"].unique_id == "000000001" @@ -82,7 +82,7 @@ async def test_user_discovery_default_selects_first_and_queues_other( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.1"} assert result["result"].unique_id == "000000001" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -120,7 +120,7 @@ async def test_broadcast_skips_already_configured_controller( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" @@ -143,7 +143,7 @@ async def test_user_discovery_skips_yaml_excluded_controllers( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" @@ -172,7 +172,7 @@ async def test_broadcast_multiple_unconfigured_shows_choice( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000001" entries = hass.config_entries.async_entries(DOMAIN) @@ -251,6 +251,7 @@ async def test_select_controller_creates_selected_uid_and_queues_others( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" + assert result["data"] == {CONF_HOST: "192.0.2.1"} assert result["result"].unique_id == "000000002" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -331,7 +332,7 @@ async def test_reuses_existing_discovery_service( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" mock_pizone_discovery.assert_not_called() @@ -427,7 +428,7 @@ async def test_homekit_confirm_uses_discovered_host( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.3"} assert result["result"].unique_id == "000000001" @@ -882,6 +883,7 @@ async def test_integration_discovery_confirm_creates_entry( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" @@ -1230,12 +1232,12 @@ def test_async_fan_out_skips_uids_already_in_progress() -> None: async def test_async_migrate_entry_clears_legacy_data( hass: HomeAssistant, ) -> None: - """v1→v2 migration clears legacy entry data; UID and title binding is deferred. + """v1→v2 migration clears legacy entry data; UID/host binding is deferred to setup. ConfigEntryNotReady retry semantics only work inside async_setup_entry — raising from async_migrate_entry permanently lands the entry in MIGRATION_ERROR with no retry path. All network-dependent work is therefore intentionally deferred to - async_setup_entry. + async_setup_entry, which also persists CONF_HOST when the UID is resolved. """ entry = MockConfigEntry( domain=DOMAIN, @@ -1258,7 +1260,7 @@ async def test_async_migrate_entry_clears_legacy_data( await hass.async_block_till_done() assert entry.version == 2 - assert entry.data == {} + assert entry.data == {CONF_HOST: "192.0.2.1"} assert entry.unique_id == "000000001" assert entry.title == "iZone 000000001" @@ -1377,6 +1379,7 @@ async def test_setup_entry_resolves_legacy_uid_and_updates_title( assert entry.unique_id == "000000001" assert entry.title == expected_title + assert entry.data == {CONF_HOST: "192.0.2.2"} @pytest.mark.parametrize( @@ -1502,7 +1505,7 @@ async def test_setup_entry_picks_eligible_controller_after_filtering_for_legacy_ await hass.async_block_till_done() assert entry.unique_id == "000000002" - assert entry.data == {} + assert entry.data == {CONF_HOST: "192.0.2.2"} @pytest.mark.parametrize( diff --git a/tests/components/jvc_projector/snapshots/test_init.ambr b/tests/components/jvc_projector/snapshots/test_init.ambr index 0842503ea258..c907e06a8a72 100644 --- a/tests/components/jvc_projector/snapshots/test_init.ambr +++ b/tests/components/jvc_projector/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'JVC Projector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/kiosker/snapshots/test_init.ambr b/tests/components/kiosker/snapshots/test_init.ambr index 403237a6f51a..6421912a9068 100644 --- a/tests/components/kiosker/snapshots/test_init.ambr +++ b/tests/components/kiosker/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Kiosker A98BE1CE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC', 'sw_version': 'Kiosker 25.1.1', 'via_device_id': None, diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index e91b88c2a551..7c54d4ddc62d 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_state.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -74,7 +74,6 @@ 'model_id': None, 'name': 'Outlet 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -83,8 +82,8 @@ # name: test_state.3 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -105,7 +104,6 @@ 'model_id': None, 'name': 'Power strip with 2 sockets', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -164,8 +162,8 @@ # name: test_state.6 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -186,7 +184,6 @@ 'model_id': None, 'name': 'Outlet 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -195,8 +192,8 @@ # name: test_state.7 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -217,7 +214,6 @@ 'model_id': None, 'name': 'Power strip with 2 sockets', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 7d69cda3d788..5cfab33adf34 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -32,10 +32,12 @@ from homeassistant.components.knx.const import ( CONF_KNX_MCAST_PORT, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DEFAULT_ROUTING_IA, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -364,6 +366,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, ) diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr index 314a856fe17f..1cc0d93c2382 100644 --- a/tests/components/knx/snapshots/test_diagnostic.ambr +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -10,6 +10,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -48,7 +49,9 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, + 'telegram_db_postgres_dsn': '**REDACTED**', 'telegram_db_retention_days': 10, }), 'config_store': dict({ @@ -79,6 +82,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -110,6 +114,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 982284db1803..27be1a6f5d4b 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,8 +1,10 @@ """Test the KNX config flow.""" +import asyncio from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, Mock, patch +from knx_telegram_store.connection import ConnectionCheckResult, ConnectionErrorKind import pytest from xknx.exceptions import XKNXException from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration @@ -21,6 +23,8 @@ from homeassistant.components.knx.config_flow import ( DEFAULT_ENTRY_DATA, DEFAULT_ENTRY_OPTIONS, OPTION_MANUAL_TUNNEL, + _build_dsn, + _parse_dsn, ) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -41,13 +45,17 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -1065,6 +1073,7 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_STATE_UPDATER: True, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } knx_setup.assert_called_once() @@ -1690,6 +1699,7 @@ async def test_options_communication_settings( CONF_KNX_TELEGRAM_STORE_SECTION: { CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, }, ) @@ -1699,6 +1709,7 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert mock_config_entry.data == initial_data assert mock_config_entry.options == { @@ -1706,5 +1717,313 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert len(knx_setup.mock_calls) == 2 + + +async def _advance_to_postgres_step( + hass: HomeAssistant, flow_id: str, *, retention_days: int = 14 +) -> config_entries.ConfigFlowResult: + """Select the PostgreSQL backend and land on its connection step.""" + result = await hass.config_entries.options.async_configure( + flow_id, + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_STORE_SECTION: { + CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: retention_days, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + }, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert not result["errors"] + return result + + +async def test_options_telegram_store_postgres( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow selecting the PostgreSQL telegram store backend.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_POSTGRES + ) + assert mock_config_entry.options[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] == 14 + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://knx:s3cret@db.local:5432/knx_telegrams?sslmode=require" + ) + assert len(knx_setup.mock_calls) == 2 + + +async def test_options_telegram_store_postgres_reuses_password( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL store reuses the stored password when left blank.""" + existing_dsn = "postgresql://olduser:oldpass@old.host:6543/olddb?sslmode=require" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: existing_dsn, + }, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"], retention_days=7) + + # Submit with an empty password - the existing one (parsed from the DSN) + # must be reused. + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "new.host", + "port": 5432, + "user": "newuser", + "password": "", + "database": "newdb", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://newuser:oldpass@new.host:5432/newdb" + ) + assert len(knx_setup.mock_calls) == 2 + + +@pytest.mark.parametrize( + ("error_kind", "expected_error"), + [ + pytest.param(ConnectionErrorKind.AUTH, "invalid_auth", id="invalid_auth"), + pytest.param( + ConnectionErrorKind.HOST_UNREACHABLE, + "host_unreachable", + id="host_unreachable", + ), + ], +) +async def test_options_telegram_store_postgres_connection_failure( + hass: HomeAssistant, + knx_setup: AsyncMock, + mock_config_entry: MockConfigEntry, + error_kind: ConnectionErrorKind, + expected_error: str, +) -> None: + """Test the PostgreSQL step maps connection check failures to form errors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.failure(error_kind, "check failed"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "wrong_password", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": expected_error} + + +async def test_options_telegram_store_postgres_timeout( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow surfaces a timeout when the connection check hangs.""" + + async def hanging_check(dsn: str) -> None: + await asyncio.Event().wait() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with ( + patch("homeassistant.components.knx.config_flow.DSN_CHECK_TIMEOUT", 0.05), + patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + side_effect=hanging_check, + ), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "timeout"} + + +async def test_options_telegram_store_postgres_malformed_dsn( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL step maps a DSN the driver rejects to a form error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + # An unterminated bracketed IPv6 address makes engine creation + # raise ValueError before any connection attempt. + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "[::1", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.parametrize( + ("dsn", "expected"), + [ + pytest.param("", {}, id="empty"), + # Invalid port makes urlparse.port raise ValueError -> {} + pytest.param("postgresql://host:notaport/db", {}, id="invalid_port"), + pytest.param( + "postgresql://u:p@h:5432/db?sslmode=require", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db", + "tls": True, + }, + id="full", + ), + pytest.param( + "postgresql://user%40domain:p%40ss%25word@h:5432/db", + { + "user": "user@domain", + "password": "p@ss%word", + "host": "h", + "port": 5432, + "database": "db", + "tls": False, + }, + id="percent_encoded_credentials", + ), + pytest.param( + "postgresql://u:p@[2001:db8::1]:5432/db", + { + "user": "u", + "password": "p", + "host": "2001:db8::1", + "port": 5432, + "database": "db", + "tls": False, + }, + id="ipv6_host", + ), + pytest.param( + "postgresql://u:p@h:5432/db%3Fquery%23hash", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db?query#hash", + "tls": False, + }, + id="percent_encoded_database", + ), + ], +) +def test_parse_dsn(dsn: str, expected: dict) -> None: + """Test PostgreSQL DSN parsing, including malformed input.""" + assert _parse_dsn(dsn) == expected + + +@pytest.mark.parametrize( + ("user", "password", "host", "database"), + [ + pytest.param("simple", "plain", "localhost", "knx", id="plain"), + pytest.param("user@domain", "p@ss", "localhost", "knx", id="at_sign"), + pytest.param("user", "p@ss%word", "localhost", "knx", id="percent_sign"), + pytest.param( + "us:er", "p/a:s@s", "localhost", "knx", id="multiple_special_chars" + ), + pytest.param("user", "pass", "2001:db8::1", "knx", id="ipv6_host"), + pytest.param( + "user", "pass", "localhost", "knx?query#hash", id="database_special_chars" + ), + ], +) +def test_dsn_round_trip(user: str, password: str, host: str, database: str) -> None: + """Test _build_dsn -> _parse_dsn -> _build_dsn produces identical DSNs. + + Catches double percent-encoding: urlparse returns percent-encoded values, + so _parse_dsn must decode them before they are fed back into _build_dsn. + IPv6 hosts must be bracketed in the netloc for the DSN to stay parseable. + Database names with URL delimiters are percent-encoded to prevent truncation. + """ + params = { + "user": user, + "password": password, + "host": host, + "port": 5432, + "database": database, + "tls": False, + } + dsn1 = _build_dsn(params) + parsed = _parse_dsn(dsn1) + dsn2 = _build_dsn(parsed) + assert dsn1 == dsn2 diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py index 98e35d16db0f..5e25c7f1651c 100644 --- a/tests/components/knx/test_date.py +++ b/tests/components/knx/test_date.py @@ -5,7 +5,11 @@ from homeassistant.components.date import ( DOMAIN as DATE_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -92,6 +96,33 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2024-02-24" +async def test_date_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("date.test", "2023-07-24") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("date.test") + assert state.state == "2023-07-24" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x18, 0x02, 0x18)) + state = hass.states.get("date.test") + assert state.state == "2024-02-24" + + async def test_date_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index b79e8abe8a63..e8107b068ef8 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -5,7 +5,11 @@ from homeassistant.components.datetime import ( DOMAIN as DATETIME_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateTimeSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -96,6 +100,36 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2020-01-01T18:04:05+00:00" +async def test_datetime_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX datetime with state_address restores state until bus read completes.""" + await hass.config.async_set_time_zone("Europe/Vienna") + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("datetime.test", "2022-03-03T03:04:05+00:00") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateTimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80) + ) + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T18:04:05+00:00" + + async def test_datetime_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index f35bad74eb46..2f1aa1e8c0a0 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -20,6 +20,7 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DEFAULT_ROUTING_IA, DOMAIN, ) @@ -100,6 +101,11 @@ async def test_diagnostic_redact( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_authentication", CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, + options={ + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: ( + "postgresql://knx:supersecret@localhost:5432/knx_telegrams" + ), + }, ) knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) await knx.setup_integration() diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 5a114762f649..87ddd2f8c048 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -38,12 +38,14 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -437,3 +439,30 @@ async def test_async_migrate_entry_future_version(hass: HomeAssistant) -> None: with patch("homeassistant.components.knx.async_setup_entry", return_value=True): assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_async_migrate_entry_v2_to_v2_2(hass: HomeAssistant) -> None: + """Test KNX config entry migration from v2.x to v2.2.""" + config_entry = MockConfigEntry( + title="KNX", + domain=DOMAIN, + version=2, + minor_version=1, + data={ + "other_setting": "some_value", + }, + options={ + "some_option": "value", + }, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.knx.async_setup_entry", return_value=True): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.version == 2 + assert config_entry.minor_version == 2 + assert ( + config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_SQLITE + ) diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index f4b8856cabe4..e00e3dfe4c44 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -5,7 +5,11 @@ from typing import Any import pytest -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import NumberSchema from homeassistant.const import CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, State @@ -112,6 +116,43 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) assert state.state == "9000.96" +async def test_number_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX number with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + + RESTORE_DATA = { + "native_max_value": None, # Ignored by KNX number + "native_min_value": None, # Ignored by KNX number + "native_step": None, # Ignored by KNX number + "native_unit_of_measurement": None, # Ignored by KNX number + "native_value": 160.0, + } + mock_restore_cache_with_extra_data( + hass, ((State("number.test", "abc"), RESTORE_DATA),) + ) + + await knx.setup_integration( + { + NumberSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_TYPE: "illuminance", + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("number.test") + assert state.state == "160.0" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x4E, 0xDE)) + state = hass.states.get("number.test") + assert state.state == "9000.96" + + @pytest.mark.parametrize( "attribute_config", [ diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index b53dfae2658b..3bec54abed1b 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -125,6 +125,40 @@ async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit) -> Non await knx.assert_no_telegram() +async def test_select_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX select with state_address restores state until bus read completes.""" + _options = [ + {CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + ] + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("select.test", "Control - On") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + SelectSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_PAYLOAD_LENGTH: 0, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("select.test") + assert state.state == "Control - On" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, 0b10) + state = hass.states.get("select.test") + assert state.state == "Control - Off" + + async def test_select_dpt_20_103_all_options( hass: HomeAssistant, knx: KNXTestKit ) -> None: diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index add2fff644f8..5912938256c2 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from copy import copy from datetime import datetime from unittest.mock import AsyncMock, patch @@ -11,9 +12,12 @@ from knx_telegram_store import KnxTelegramStoreException, StoredTelegram, Telegr import pytest from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR, ) from homeassistant.components.knx.telegrams import TelegramDict @@ -156,6 +160,34 @@ async def test_store_telegram_history_error_handling( assert issue is not None +async def test_store_telegram_history_needs_migration_timeout( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test that store initialization is aborted when needs_migration times out.""" + + async def hanging_probe() -> bool: + await asyncio.Event().wait() + return False + + with ( + patch("homeassistant.components.knx.telegrams.STORE_INIT_TIMEOUT", 0.05), + patch( + "knx_telegram_store.BufferedSqliteStore.needs_migration", + side_effect=hanging_probe, + ), + ): + await knx.setup_integration() + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + # Check that the repair issue was created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + assert issue is not None + + async def test_migrate_telegrams_from_json( hass: HomeAssistant, knx: KNXTestKit, @@ -483,3 +515,39 @@ async def test_nightly_eviction_error_handling( assert "Database error evicting expired KNX telegrams" in caplog.text # Store remains operational after the failed eviction assert telegrams_module.store is not None + + +async def test_postgres_backend_init_error( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test PostgreSQL backend DSN handling and init failure path.""" + dsn = "postgresql://user:secret@db.local:5432/knx" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: dsn, + }, + ) + + # Mock the store to avoid constructing a real SQLAlchemy engine / connecting. + mock_store = AsyncMock() + mock_store.needs_migration.return_value = False + mock_store.initialize.side_effect = KnxTelegramStoreException("no server") + with patch( + "homeassistant.components.knx.telegrams.BufferedPostgresStore", + return_value=mock_store, + ): + await knx.setup_integration(add_entry_to_hass=False) + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + is not None + ) diff --git a/tests/components/knx/test_text.py b/tests/components/knx/test_text.py index b2222ff025b8..7eb25399db5f 100644 --- a/tests/components/knx/test_text.py +++ b/tests/components/knx/test_text.py @@ -1,6 +1,10 @@ """Test KNX number.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TextSchema from homeassistant.components.text import TextMode from homeassistant.const import CONF_NAME, Platform @@ -103,6 +107,36 @@ async def test_text_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "hallo" +async def test_text_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX text with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("text.test", "test test") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TextSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("text.test") + assert state.state == "test test" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, + (0x68, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0), + ) + state = hass.states.get("text.test") + assert state.state == "hallo" + + async def test_text_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_time.py b/tests/components/knx/test_time.py index 08a4edff70f4..19e069706d3d 100644 --- a/tests/components/knx/test_time.py +++ b/tests/components/knx/test_time.py @@ -1,6 +1,10 @@ """Test KNX time.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TimeSchema from homeassistant.components.time import ( ATTR_TIME, @@ -92,6 +96,33 @@ async def test_time_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "12:00:00" +async def test_time_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX time with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("time.test", "01:02:03") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("time.test") + assert state.state == "01:02:03" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x0C, 0x00, 0x00)) + state = hass.states.get("time.test") + assert state.state == "12:00:00" + + async def test_time_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 0f5f9af1c37f..124122d99117 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -10,8 +10,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, KNX_ADDRESS, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, SUPPORTED_PLATFORMS_UI, ) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY @@ -37,10 +40,46 @@ async def test_knx_get_base_data_command( assert res["result"]["connection_info"]["version"] is not None assert res["result"]["connection_info"]["connected"] assert res["result"]["connection_info"]["current_address"] == "0.0.0" + assert res["result"]["connection_info"]["telegram_backend"] == "sqlite" assert res["result"]["project_info"] is None assert not SUPPORTED_PLATFORMS_UI.difference(res["result"]["supported_platforms"]) +async def test_knx_get_base_data_command_postgres( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +) -> None: + """Test knx/get_base_data reports the PostgreSQL telegram backend.""" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: "postgresql://user:pw@db.local:5432/knx", + }, + ) + # Patch methods on the real class so the isinstance check in the + # websocket handler still sees a BufferedPostgresStore instance. + with ( + patch( + "knx_telegram_store.BufferedPostgresStore.needs_migration", + return_value=False, + ), + patch("knx_telegram_store.BufferedPostgresStore.initialize"), + patch( + "knx_telegram_store.BufferedPostgresStore.get_last_unique_telegrams", + return_value=[], + ), + ): + await knx.setup_integration(add_entry_to_hass=False) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_base_data"}) + res = await client.receive_json() + + assert res["success"], res + assert res["result"]["connection_info"]["telegram_backend"] == "postgres" + + @pytest.mark.usefixtures("load_knxproj") async def test_knx_get_base_data_command_with_project( hass: HomeAssistant, diff --git a/tests/components/lamarzocco/snapshots/test_bluetooth.ambr b/tests/components/lamarzocco/snapshots/test_bluetooth.ambr index 7749a94d7d98..177ebb7daa47 100644 --- a/tests/components/lamarzocco/snapshots/test_bluetooth.ambr +++ b/tests/components/lamarzocco/snapshots/test_bluetooth.ambr @@ -16,8 +16,8 @@ # name: test_setup_through_bluetooth_only[GS3 AV-entities1][device_bluetooth_GS012345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,7 +42,6 @@ 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GS012345', 'sw_version': None, 'via_device_id': None, @@ -161,8 +160,8 @@ # name: test_setup_through_bluetooth_only[Linea Micra-entities0][device_bluetooth_MR012345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -187,7 +186,6 @@ 'model_id': 'LINEAMICRA', 'name': 'MR012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'MR012345', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index bdebd35d6dda..6f9ff0084e3c 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GS012345', 'sw_version': 'v1.17', 'via_device_id': None, diff --git a/tests/components/led_infrared/__init__.py b/tests/components/led_infrared/__init__.py new file mode 100644 index 000000000000..1d9171961d86 --- /dev/null +++ b/tests/components/led_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the LED Infrared integration.""" diff --git a/tests/components/led_infrared/conftest.py b/tests/components/led_infrared/conftest.py new file mode 100644 index 000000000000..b608b97a0007 --- /dev/null +++ b/tests/components/led_infrared/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the LED Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.led_infrared.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + + +@pytest.fixture(name="infrared_codes") +def mock_infrared_code_to_command() -> Generator[None]: + """Patch to_command to return the code directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with ( + patch( + "infrared_protocols.codes.generic.led.Generic24KeyCode.to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ) as mock_to_command, + patch( + "infrared_protocols.codes.generic.led.Generic13KeyCode.to_command", + new=mock_to_command, + ), + ): + yield diff --git a/tests/components/led_infrared/snapshots/test_diagnostics.ambr b/tests/components/led_infrared/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..f137fce72065 --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_diagnostics.ambr @@ -0,0 +1,7 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device_type': 'generic_24_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + }) +# --- diff --git a/tests/components/led_infrared/snapshots/test_light.ambr b/tests/components/led_infrared/snapshots/test_light.ambr new file mode 100644 index 000000000000..ae797297dfa0 --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_light.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[light.led_infrared_via_test_ir_emitter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + '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': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.led_infrared_via_test_ir_emitter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : None, + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : 'LED Infrared via Test IR emitter', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py new file mode 100644 index 000000000000..dcbdab4cd4ea --- /dev/null +++ b/tests/components/led_infrared/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the LED Infrared config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "LED light with 24-key remote via Test IR emitter" + assert result["data"] == { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test we abort when already configured.""" + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_requires_emitter( + hass: HomeAssistant, +) -> None: + """Test user flow requires an infrared emitter.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure(hass: HomeAssistant) -> None: + """Test reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + result = await 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_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_INFRARED_ENTITY_ID] == EMITTER_ENTITY_ID + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_reconfigure_flow_requires_emitter( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow requires an infrared emitter.""" + config_entry.add_to_hass(hass) + result = await 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"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="0987654321", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + config_entry_2.add_to_hass(hass) + result = await config_entry_2.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_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_reconfigure_flow_no_emitters( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow aborts when no infrared emitters exist.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" diff --git a/tests/components/led_infrared/test_diagnostics.py b/tests/components/led_infrared/test_diagnostics.py new file mode 100644 index 000000000000..16f913428770 --- /dev/null +++ b/tests/components/led_infrared/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test for diagnostics platform of the LED Infrared integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +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, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/led_infrared/test_init.py b/tests/components/led_infrared/test_init.py new file mode 100644 index 000000000000..4e8cf794c27a --- /dev/null +++ b/tests/components/led_infrared/test_init.py @@ -0,0 +1,22 @@ +"""Tests for the LED Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/led_infrared/test_light.py b/tests/components/led_infrared/test_light.py new file mode 100644 index 000000000000..1e2083265ac5 --- /dev/null +++ b/tests/components/led_infrared/test_light.py @@ -0,0 +1,280 @@ +"""Tests for the LED Infrared light platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture(autouse=True) +def light_only() -> Generator[None]: + """Enable only the light platform.""" + with patch( + "homeassistant.components.led_infrared.PLATFORMS", + [Platform.LIGHT], + ): + yield + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of light platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device_type", "service", "service_data", "expected_codes"), + [ + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {}, + [Generic24KeyCode.ON], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "flash"}, + [Generic24KeyCode.ON, Generic24KeyCode.FLASH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "strobe"}, + [Generic24KeyCode.ON, Generic24KeyCode.STROBE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "fade"}, + [Generic24KeyCode.ON, Generic24KeyCode.FADE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "smooth"}, + [Generic24KeyCode.ON, Generic24KeyCode.SMOOTH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "red"}, + [Generic24KeyCode.ON, Generic24KeyCode.RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "green"}, + [Generic24KeyCode.ON, Generic24KeyCode.GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "white"}, + [Generic24KeyCode.ON, Generic24KeyCode.WHITE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange_red"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE_RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "tomato"}, + [Generic24KeyCode.ON, Generic24KeyCode.TOMATO], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "light_green"}, + [Generic24KeyCode.ON, Generic24KeyCode.LIGHT_GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "sky_blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.SKY_BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "rebecca_purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.REBECCA_PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "turquoise"}, + [Generic24KeyCode.ON, Generic24KeyCode.TURQUOISE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "yellow"}, + [Generic24KeyCode.ON, Generic24KeyCode.YELLOW], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "dark_cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.DARK_CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "plum"}, + [Generic24KeyCode.ON, Generic24KeyCode.PLUM], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_OFF, + {}, + [Generic24KeyCode.OFF], + ), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_ON, {}, [Generic13KeyCode.ON]), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_OFF, {}, [Generic13KeyCode.OFF]), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_1"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_1], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_2"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_2], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_3"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_3], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_4"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_4], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_5"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_5], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_6"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_6], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_7"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_7], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_8"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_8], + ), + ], +) +@pytest.mark.usefixtures("infrared_codes") +async def test_light_actions( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + device_type: LEDIrDeviceType, + service: str, + service_data: dict[str, str], + expected_codes: list[Generic24KeyCode | Generic13KeyCode], +) -> None: + """Test light actions.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: device_type, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.led_infrared_via_test_ir_emitter", **service_data}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == len(expected_codes) + assert mock_infrared_emitter_entity.send_command_calls == expected_codes diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index e1b5a48fe27b..df0111f63531 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '1p7k_500006', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '500006', 'sw_version': '1.44', 'via_device_id': None, diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index e9bfe2056645..003a1240299c 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -86,3 +87,25 @@ async def test_fan_mode_service_calls( coordinator.api.async_set_fan_mode.assert_awaited_once_with( "climate_air_conditioner", expected_value ) + + +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +@pytest.mark.usefixtures("devices") +async def test_service_call_connection_error_raises_home_assistant_error( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a network error during a service call raises HomeAssistantError.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + mock_thinq_api.async_post_device_control.side_effect = TimeoutError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_air_conditioner", "fan_mode": "low"}, + blocking=True, + ) diff --git a/tests/components/lichess/snapshots/test_init.ambr b/tests/components/lichess/snapshots/test_init.ambr index 91ba6b5d91d5..9b2efcf1196a 100644 --- a/tests/components/lichess/snapshots/test_init.ambr +++ b/tests/components/lichess/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'DrNykterstein', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/litellm/__init__.py b/tests/components/litellm/__init__.py new file mode 100644 index 000000000000..27b3e0e89ba2 --- /dev/null +++ b/tests/components/litellm/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the LiteLLM integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str: + """Get the subentry ID for a given type.""" + ids = [ + subentry_id + for subentry_id, subentry in mock_config_entry.subentries.items() + if subentry.subentry_type == subentry_type + ] + if not ids: + raise ValueError(f"No subentry found for type {subentry_type}") + return ids[0] diff --git a/tests/components/litellm/conftest.py b/tests/components/litellm/conftest.py new file mode 100644 index 000000000000..84ebbac639ed --- /dev/null +++ b/tests/components/litellm/conftest.py @@ -0,0 +1,133 @@ +"""Fixtures for LiteLLM integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage, Model +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +TEST_URL = "http://localhost:4000/v1" + + +async def models_response(*model_ids: str) -> AsyncGenerator[Model]: + """Yield models as the OpenAI client's `models.list()` would.""" + for model_id in model_ids: + yield Model(id=model_id, created=0, object="model", owned_by="litellm") + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.litellm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Return whether the Assist LLM API is enabled for the conversation agent.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="localhost:4000", + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="gpt-3.5-turbo", + unique_id=None, + ), + ], + ) + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Mock the OpenAI client used for chat completions.""" + with patch( + "homeassistant.components.litellm.coordinator.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +def mock_models() -> Generator[AsyncMock]: + """Mock the OpenAI client the config flow uses to list proxy models.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-3.5-turbo", "gpt-4") + ) + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/litellm/snapshots/test_conversation.ambr b/tests/components/litellm/snapshots/test_conversation.ambr new file mode 100644 index 000000000000..a905dbed6dce --- /dev/null +++ b/tests/components/litellm/snapshots/test_conversation.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-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': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-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': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'What time is it?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock_tool_call_id', + 'tool_args': dict({ + }), + 'tool_name': 'HassGetCurrentTime', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', + 'tool_result': dict({ + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), + }), + }), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': '12:00 PM', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True].1 + list([ + dict({ + 'content': ''' + You are a helpful assistant. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'What time is it?', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{}', + 'name': 'HassGetCurrentTime', + }), + 'id': 'mock_tool_call_id', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'role': 'tool', + 'tool_call_id': 'mock_tool_call_id', + }), + dict({ + 'content': '12:00 PM', + 'role': 'assistant', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{"param1":"call1"}', + 'name': 'test_tool', + }), + 'id': 'call_call_1', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '"value1"', + 'role': 'tool', + 'tool_call_id': 'call_call_1', + }), + dict({ + 'content': 'I have successfully called the function', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py new file mode 100644 index 000000000000..ac3a1675ab11 --- /dev/null +++ b/tests/components/litellm/test_config_flow.py @@ -0,0 +1,397 @@ +"""Test the LiteLLM config flow.""" + +from unittest.mock import AsyncMock, patch + +import httpx +from openai import ( + APIConnectionError, + APITimeoutError, + AuthenticationError, + PermissionDeniedError, +) +import pytest + +from homeassistant.components.litellm.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +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 . import get_subentry_id, setup_integration +from .conftest import TEST_URL, models_response + +from tests.common import MockConfigEntry + +CONVERSATION_MODEL_OPTIONS = [ + {"value": "gpt-3.5-turbo", "label": "gpt-3.5-turbo"}, + {"value": "gpt-4", "label": "gpt-4"}, +] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +@pytest.mark.parametrize( + "url_input", + ["http://localhost:4000", "http://localhost:4000/", TEST_URL, f"{TEST_URL}/"], +) +async def test_full_flow(hass: HomeAssistant, url_input: str) -> None: + """Test the full config flow normalizes the URL and stores the key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url_input, CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "localhost" + assert result["data"] == {CONF_URL: TEST_URL, CONF_API_KEY: "bla"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +async def test_full_flow_without_api_key(hass: HomeAssistant) -> None: + """Test the config flow works without an API key.""" + 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_URL: "http://localhost:4000"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: TEST_URL} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + exception: Exception, + error: str, +) -> None: + """Test we handle errors and can recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + ) as mock_get_models: + mock_get_models.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_models.side_effect = None + mock_get_models.return_value = {"gpt-3.5-turbo": {}} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +def _status_error( + error: type[AuthenticationError | PermissionDeniedError], status_code: int +) -> AuthenticationError | PermissionDeniedError: + """Build an OpenAI status error backed by a real httpx response.""" + return error( + response=httpx.Response( + status_code=status_code, request=httpx.Request("GET", TEST_URL) + ), + body=None, + message="error", + ) + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (_status_error(AuthenticationError, 401), "invalid_auth"), + (_status_error(PermissionDeniedError, 403), "invalid_auth"), + (APIConnectionError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + (APITimeoutError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + ], +) +async def test_user_step_proxy_errors( + hass: HomeAssistant, + side_effect: Exception, + error: str, +) -> None: + """Test the user step surfaces errors raised by the OpenAI client.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.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_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry with the same URL already exists.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "other"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert ( + result["data_schema"].schema["model"].config["options"] + == CONVERSATION_MODEL_OPTIONS + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gpt-3.5-turbo" + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } + + +async def test_conversation_agent_model_options( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the model dropdown is populated from the proxy's model list.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-4o", "gpt-5") + ) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-4o", "label": "gpt-4o"}, + {"value": "gpt-5", "label": "gpt-5"}, + ] + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test subentry flow aborts on errors fetching models.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + side_effect=exception, + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a conversation agent.""" + 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) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-4", + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.title == "gpt-4" + assert subentry.data[CONF_MODEL] == "gpt-4" + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert subentry.data[CONF_LLM_HASS_API] == ["assist"] + + +async def test_reconfigure_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring aborts when the main entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.parametrize( + ("current_llm_apis", "suggested_llm_apis", "expected_options"), + [ + (["assist"], ["assist"], ["assist"]), + (["non-existent"], [], ["assist"]), + (["assist", "non-existent"], ["assist"], ["assist"]), + ], +) +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_subentry_llm_api_schema( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + current_llm_apis: list[str], + suggested_llm_apis: list[str], + expected_options: list[str], +) -> None: + """Test llm_hass_api field values when reconfiguring a conversation subentry.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + subentry = mock_config_entry.subentries[subentry_id] + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis}, + ) + await hass.async_block_till_done() + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == suggested_llm_apis + + field_schema = schema[key] + assert field_schema.config + assert [ + opt["value"] for opt in field_schema.config.get("options") + ] == expected_options diff --git a/tests/components/litellm/test_conversation.py b/tests/components/litellm/test_conversation.py new file mode 100644 index 000000000000..1f3660a3b98d --- /dev/null +++ b/tests/components/litellm/test_conversation.py @@ -0,0 +1,297 @@ +"""Tests for the LiteLLM conversation entity.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ToolInput + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + +AGENT_ID = "conversation.gpt_3_5_turbo" + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.litellm.PLATFORMS", + [Platform.CONVERSATION], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "gpt-3.5-turbo" + assert "extra_headers" not in call + + +async def test_empty_api_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an empty choices response raises an error.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an error talking to the API is handled gracefully.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.OpenAIError("boom") + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_connection_error_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test a connection error marks the entity unavailable until it recovers.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.APIConnectionError( + request=httpx.Request("POST", "http://localhost") + ) + ) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + assert result.response.response_type is intent.IntentResponseType.ERROR + + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state == STATE_UNAVAILABLE + + # A successful availability ping restores the entity. + await mock_config_entry.runtime_data.async_request_refresh() + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.async_add_user_content( + conversation.UserContent(content="What time is it?") + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + tool_calls=[ + ToolInput( + tool_name="HassGetCurrentTime", + tool_args={}, + id="mock_tool_call_id", + external=True, + ) + ], + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.ToolResultContent( + agent_id=AGENT_ID, + tool_call_id="mock_tool_call_id", + tool_name="HassGetCurrentTime", + tool_result={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + }, + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + content="12:00 PM", + ) + ) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + mock_openai_client.chat.completions.create.side_effect = ( + ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot + assert mock_openai_client.chat.completions.create.call_count == 2 + assert ( + mock_openai_client.chat.completions.create.call_args.kwargs["messages"] + == snapshot + ) diff --git a/tests/components/litellm/test_init.py b/tests/components/litellm/test_init.py new file mode 100644 index 000000000000..151f783633dc --- /dev/null +++ b/tests/components/litellm/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the LiteLLM integration setup.""" + +from unittest.mock import AsyncMock + +import httpx +from openai import APIConnectionError, AuthenticationError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + AuthenticationError( + response=httpx.Response( + status_code=401, request=httpx.Request("GET", "http://localhost") + ), + body=None, + message="invalid api key", + ), + ConfigEntryState.SETUP_ERROR, + ), + (APIConnectionError(request=None), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test that setup handles errors validating the connection.""" + mock_openai_client.with_options.return_value.models.list.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state diff --git a/tests/components/lojack/snapshots/test_init.ambr b/tests/components/lojack/snapshots/test_init.ambr index b23664dd0329..8be5adc20beb 100644 --- a/tests/components/lojack/snapshots/test_init.ambr +++ b/tests/components/lojack/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '2021 Honda Accord', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1HGBH41JXMN109186', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 662ffd51cb46..0159ced905e7 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mastodon @trwnh@mastodon.social', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index ce8035f289b0..5ce8158cedce 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mealie', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'v3.7.0', 'via_device_id': None, diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 654e631cdda7..a6ae48bbdba0 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Meater Probe 40a72384', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/melnor/snapshots/test_init.ambr b/tests/components/melnor/snapshots/test_init.ambr index 575043cb8cdc..def342f7dea0 100644 --- a/tests/components/melnor/snapshots/test_init.ambr +++ b/tests/components/melnor/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'test_melnor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index b5b830f4e5cb..0071bfd10e81 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'FNS 28463 E ed/', 'name': 'Freezer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'Dummy_Appliance_1', 'sw_version': '31.17', 'via_device_id': None, diff --git a/tests/components/modbus_connection/__init__.py b/tests/components/modbus_connection/__init__.py deleted file mode 100644 index ecbad3432af6..000000000000 --- a/tests/components/modbus_connection/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Modbus Connection integration.""" diff --git a/tests/components/modbus_connection/conftest.py b/tests/components/modbus_connection/conftest.py deleted file mode 100644 index 379fcd664435..000000000000 --- a/tests/components/modbus_connection/conftest.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Common fixtures for the Modbus Connection tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -from modbus_connection.mock import MockModbusConnection -import pytest - -from homeassistant.components.modbus_connection.const import CONNECTION_TCP, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Prevent the created entry from actually setting up during flow tests.""" - with patch( - "homeassistant.components.modbus_connection.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_connect( - mock_modbus_connection: MockModbusConnection, -) -> Generator[AsyncMock]: - """Patch the backend connect functions to return the mock connection.""" - connect = AsyncMock(return_value=mock_modbus_connection) - with ( - patch("homeassistant.components.modbus_connection.connect_tcp", connect), - patch("homeassistant.components.modbus_connection.connect_serial", connect), - ): - yield connect - - -@pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: - """Return a TCP connection config entry, already added to hass.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="1.2.3.4:502", - data={CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - ) - entry.add_to_hass(hass) - return entry - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connect: AsyncMock, -) -> MockConfigEntry: - """Set up the connection entry (loaded). - - Relies on ``mock_config_entry`` already being in hass. - """ - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry diff --git a/tests/components/modbus_connection/test_config_flow.py b/tests/components/modbus_connection/test_config_flow.py deleted file mode 100644 index cb5d4d199caf..000000000000 --- a/tests/components/modbus_connection/test_config_flow.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Tests for the Modbus Connection config flow.""" - -from typing import Any -from unittest.mock import AsyncMock - -from modbus_connection import ModbusConnectionError -from modbus_connection.mock import MockModbusConnection -import pytest - -from homeassistant.components.modbus_connection.const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DOMAIN, -) -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - -SERIAL_INPUT = { - CONF_DEVICE: "/dev/ttyUSB0", - CONF_BAUDRATE: 9600, - CONF_PARITY: "n", - CONF_STOPBITS: 1, - CONF_BYTESIZE: 8, -} - - -async def _start_menu(hass: HomeAssistant, step: str) -> str: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.MENU - assert set(result["menu_options"]) == {"modbus_tcp", "serial"} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": step} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == step - return result["flow_id"] - - -@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") -async def test_modbus_tcp_flow(hass: HomeAssistant) -> None: - """The Modbus TCP step opens the connection and creates an entry.""" - flow_id = await _start_menu(hass, "modbus_tcp") - result = await hass.config_entries.flow.async_configure( - flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_TYPE: CONNECTION_TCP, - CONF_HOST: "1.2.3.4", - CONF_PORT: 502, - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_modbus_tcp_cannot_connect_then_recovers( - hass: HomeAssistant, - mock_connect: AsyncMock, - mock_modbus_connection: MockModbusConnection, -) -> None: - """A failed probe shows an error; a later success creates the entry.""" - flow_id = await _start_menu(hass, "modbus_tcp") - mock_connect.side_effect = ModbusConnectionError("nope") - result = await hass.config_entries.flow.async_configure( - flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - mock_connect.side_effect = None - mock_connect.return_value = mock_modbus_connection - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") -async def test_serial_flow(hass: HomeAssistant) -> None: - """The serial step opens the connection and creates a serial entry.""" - flow_id = await _start_menu(hass, "serial") - result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) - assert result["type"] is FlowResultType.CREATE_ENTRY - # Parity is stored uppercase (the code the connection expects). - assert result["data"] == { - CONF_TYPE: CONNECTION_SERIAL, - **SERIAL_INPUT, - CONF_PARITY: "N", - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_serial_cannot_open(hass: HomeAssistant, mock_connect: AsyncMock) -> None: - """A failed serial open shows the serial-specific error.""" - flow_id = await _start_menu(hass, "serial") - mock_connect.side_effect = ModbusConnectionError("nope") - result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_open_serial_port"} - - -@pytest.mark.parametrize( - ("step", "data", "user_input"), - [ - pytest.param( - "modbus_tcp", - {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - {CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - id="modbus_tcp", - ), - pytest.param( - "serial", - {CONF_TYPE: CONNECTION_SERIAL, **SERIAL_INPUT}, - SERIAL_INPUT, - id="serial", - ), - ], -) -async def test_duplicate_aborts( - hass: HomeAssistant, - step: str, - data: dict[str, Any], - user_input: dict[str, Any], -) -> None: - """Re-adding an already-configured link aborts before opening it. - - The dedupe runs before opening the connection, so no connect is needed. - """ - MockConfigEntry(domain=DOMAIN, data=data).add_to_hass(hass) - - flow_id = await _start_menu(hass, step) - result = await hass.config_entries.flow.async_configure(flow_id, user_input) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/modbus_connection/test_init.py b/tests/components/modbus_connection/test_init.py deleted file mode 100644 index 9b49de25ef39..000000000000 --- a/tests/components/modbus_connection/test_init.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for Modbus Connection setup, teardown and the async_get_unit accessor.""" - -from typing import Any -from unittest.mock import AsyncMock, patch - -from modbus_connection import ModbusConnectionError, ModbusError -from modbus_connection.mock import MockModbusConnection, MockModbusUnit -import pytest - -from homeassistant.components.modbus_connection import ( - ConnectionNotReady, - async_get_unit, -) -from homeassistant.components.modbus_connection.const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DOMAIN, -) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_setup_and_unload( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, -) -> None: - """A connection entry loads, exposes runtime data, and closes on unload.""" - assert init_integration.state is ConfigEntryState.LOADED - assert init_integration.runtime_data is mock_modbus_connection - assert mock_modbus_connection.connected is True - - 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 mock_modbus_connection.connected is False - - -@pytest.mark.parametrize( - ("data", "error"), - [ - pytest.param( - {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - ModbusConnectionError("boom"), - id="tcp", - ), - pytest.param( - { - CONF_TYPE: CONNECTION_SERIAL, - CONF_DEVICE: "/dev/ttyUSB0", - CONF_BAUDRATE: 9600, - CONF_PARITY: "N", - CONF_STOPBITS: 1, - CONF_BYTESIZE: 8, - }, - ModbusError("port busy"), - id="serial", - ), - ], -) -async def test_setup_retry_when_connect_fails( - hass: HomeAssistant, - mock_connect: AsyncMock, - data: dict[str, Any], - error: ModbusError, -) -> None: - """A failed open raises ConfigEntryNotReady (setup retry). - - The serial case uses a generic ``ModbusError`` (not a ``ModbusConnectionError``) - to confirm setup retries on any library error, matching the config flow. - """ - entry = MockConfigEntry(domain=DOMAIN, data=data) - entry.add_to_hass(hass) - mock_connect.side_effect = error - - assert not await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_connection_lost_schedules_reload( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, -) -> None: - """Losing the connection schedules a reload of the entry.""" - with patch.object(hass.config_entries, "async_schedule_reload") as schedule_reload: - mock_modbus_connection.simulate_connection_lost() - await hass.async_block_till_done() - - schedule_reload.assert_called_once_with(init_integration.entry_id) - - -async def test_get_unit_returns_connection_unit( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_unit: MockModbusUnit, -) -> None: - """async_get_unit hands back the connection's own unit handle.""" - assert async_get_unit(hass, init_integration.entry_id, 1) is mock_modbus_unit - - -async def test_get_unit_not_ready_when_unloaded( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """A modbus_connection entry that is not loaded raises ConnectionNotReady.""" - # mock_config_entry is added to hass but never set up -> not LOADED. - with pytest.raises(ConnectionNotReady): - async_get_unit(hass, mock_config_entry.entry_id, 1) - - -async def test_get_unit_rejects_invalid_entry(hass: HomeAssistant) -> None: - """An unknown entry_id or a foreign-domain entry raises ValueError.""" - with pytest.raises(ValueError): - async_get_unit(hass, "does-not-exist", 1) - - other = MockConfigEntry(domain="sun", state=ConfigEntryState.LOADED) - other.add_to_hass(hass) - with pytest.raises(ValueError): - async_get_unit(hass, other.entry_id, 1) diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 7664a1b9bdc1..c5cb4abb6660 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -274,16 +274,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d expected_helper_device_id: str | None, expected_events: list[str], ) -> None: - """Test config entry removed when the source entity is removed.""" + """Test the source entity is removed but the source device is not removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() @@ -297,15 +290,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.mold_indicator.async_unload_entry", wraps=mold_indicator.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -314,6 +304,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert mold_indicator_entity_entry.device_id == expected_helper_device_id + # Check that the source device is not removed + assert device_registry.async_get(source_device.id) is not None + # Check if the mold_indicator config entry is not in the device source_device = device_registry.async_get(source_device.id) assert mold_indicator_config_entry.entry_id not in source_device.config_entries @@ -533,7 +526,7 @@ async def test_migration_1_1( indoor_temperature_entity_entry: er.RegistryEntry, outdoor_temperature_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes mold_indicator config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" mold_indicator_config_entry = MockConfigEntry( data={}, @@ -551,25 +544,15 @@ async def test_migration_1_1( ) mold_indicator_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - indoor_humidity_device.id, - add_config_entry_id=mold_indicator_config_entry.entry_id, - ) - - # Check preconditions - switch_device = device_registry.async_get(indoor_humidity_device.id) - assert mold_indicator_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() assert mold_indicator_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device - switch_device = device_registry.async_get(switch_device.id) - assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + # Check that the helper config entry is not in the device and the helper entity + # 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") assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 2a353bb60ba5..149e423be91d 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.moon.sensor import ( +from homeassistant.components.moon.helpers import ( STATE_FIRST_QUARTER, STATE_FULL_MOON, STATE_LAST_QUARTER, @@ -47,7 +47,7 @@ async def test_moon_day( mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.moon.sensor.moon.phase", return_value=moon_value + "homeassistant.components.moon.helpers.moon.phase", return_value=moon_value ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/moon/test_trigger.py b/tests/components/moon/test_trigger.py new file mode 100644 index 000000000000..266fb2dbfa34 --- /dev/null +++ b/tests/components/moon/test_trigger.py @@ -0,0 +1,103 @@ +"""Tests for the moon triggers.""" + +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + +_PHASE = "homeassistant.components.moon.helpers.moon.phase" + + +@pytest.fixture(autouse=True) +async def setup_moon(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Set up the moon integration so its trigger platform is available.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def _arm(hass: HomeAssistant, options: dict[str, Any] | None = None) -> None: + """Set up an automation with the moon phase_changed trigger.""" + trigger: dict[str, Any] = {"platform": "moon.phase_changed"} + if options is not None: + trigger["options"] = options + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger, + "action": { + "service": "test.automation", + "data_template": { + "phase": "{{ trigger.phase }}", + "previous_phase": "{{ trigger.previous_phase }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + +def _next_local_midnight() -> datetime: + """Return the next local midnight, when the phase trigger re-evaluates.""" + return dt_util.start_of_local_day() + timedelta(days=1) + + +async def test_phase_changed_fires_on_any_change( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger fires on every phase change when unfiltered.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass) + assert len(service_calls) == 0 + + with patch(_PHASE, return_value=14.0): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["phase"] == "full_moon" + assert service_calls[0].data["previous_phase"] == "new_moon" + + +async def test_phase_changed_ignores_same_phase( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger does not fire when the phase is unchanged.""" + with patch(_PHASE, return_value=14.0): + await _arm(hass) + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 0 + + +@pytest.mark.parametrize( + ("new_value", "expected_calls"), + [(14.0, 1), (5.0, 0)], +) +async def test_phase_changed_with_phase_filter( + hass: HomeAssistant, + service_calls: list[ServiceCall], + new_value: float, + expected_calls: int, +) -> None: + """Test the trigger only fires for the configured phase.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass, options={"phase": "full_moon"}) + + with patch(_PHASE, return_value=new_value): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == expected_calls diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 1d64f4742af1..26abd4018071 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -70,6 +70,21 @@ from tests.typing import ( WebSocketGenerator, ) + +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + 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): + if device.config_entry_id == config_entry_id: + return device + return None + + TEST_SINGLE_CONFIGS = [ ( "homeassistant/device_automation/0AFFD2/bla1/config", @@ -2047,15 +2062,24 @@ async def test_cleanup_device_multiple_config_entries( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None @@ -2065,7 +2089,7 @@ async def test_cleanup_device_multiple_config_entries( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry.id, mqtt_config_entry.entry_id + mqtt_device_entry.id, mqtt_config_entry.entry_id ) assert response["success"] @@ -2165,15 +2189,24 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 1bf8a425da56..f5f4e52ce488 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -46,6 +46,20 @@ DEFAULT_TAG_SCAN_JSON = ( ) +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + 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): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.mark.no_fail_on_log_exception async def test_discover_bad_tag( hass: HomeAssistant, @@ -570,24 +584,45 @@ async def test_cleanup_tag( async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", data2) await hass.async_block_till_done() - # Verify device registry entries are created - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + # Verify device registry entries are created. Identifiers are unique per config + # entry, so the test config entry and MQTT get separate "helloworld" devices + device_entry1 = _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, ) assert device_entry1 is not None - assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id} + assert device_entry1.config_entries == {config_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None - # Remove other config entry from the device + # Removing the test config entry deletes its device; the MQTT device is untouched + # and MQTT does not clear its discovery topic device_registry.async_update_device( device_entry1.id, remove_config_entry_id=config_entry.entry_id ) - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + is None ) - assert device_entry1 is not None - assert device_entry1.config_entries == {mqtt_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None mqtt_mock.async_publish.assert_not_called() @@ -595,7 +630,7 @@ async def test_cleanup_tag( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry1.id, mqtt_config_entry.entry_id + mqtt_device_entry1.id, mqtt_config_entry.entry_id ) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/mystrom/snapshots/test_init.ambr b/tests/components/mystrom/snapshots/test_init.ambr index 76e207a295af..0538ba5cb686 100644 --- a/tests/components/mystrom/snapshots/test_init.ambr +++ b/tests/components/mystrom/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_bulb DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'myStrom Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.58.0', 'via_device_id': None, diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 66b4c9efe356..3ff976524746 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[alfred-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Gotham City', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10001', 'sw_version': '9682R7A', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info[batman-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Batcave', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10002', 'sw_version': '9682R7B', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_info[robin-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Duckburg', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10003', 'sw_version': '9682R7C', 'via_device_id': None, diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr index 96b3def82e77..7e2b4f2bb798 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr @@ -3,13 +3,13 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ @@ -25,20 +25,19 @@ 'model_id': None, 'name': 'To work', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ @@ -54,7 +53,6 @@ 'model_id': None, 'name': 'To home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index fd12cb9fb69c..b37ef2729d93 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[netatmo-0009999992] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Entrance Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[netatmo-0009999993] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Bubendorff blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[netatmo-00:11:22:33:00:11:45:fe] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Unknown 00:11:22:33:00:11:45:fe', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_devices[netatmo-1002003001] DeviceRegistryEntrySnapshot({ 'area_id': 'corridor', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Corridor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_devices[netatmo-12:34:56:00:00:a1:4c:da] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Consumption meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_devices[netatmo-12:34:56:00:01:01:01:a1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Bathroom light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': None, 'name': 'Line 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': None, 'name': 'Line 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': None, 'name': 'Line 3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#3] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': None, 'name': 'Line 4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#4] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': None, 'name': 'Line 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#5] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': None, 'name': 'Total', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#6] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': None, 'name': 'Gas', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#7] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': None, 'name': 'Hot water', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#8] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': None, 'name': 'Cold water', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': None, 'name': 'Écocompteur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_devices[netatmo-12:34:56:00:86:99] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': None, 'name': 'Window Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_devices[netatmo-12:34:56:00:f1:62] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': None, 'name': 'Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_devices[netatmo-12:34:56:03:1b:e4] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': None, 'name': 'Villa Garden', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_devices[netatmo-12:34:56:10:b9:0e] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': None, 'name': 'Front', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_devices[netatmo-12:34:56:10:f1:66] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': None, 'name': 'Netatmo-Doorbell', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_devices[netatmo-12:34:56:25:cf:a8] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_devices[netatmo-12:34:56:26:65:14] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': None, 'name': 'Livingroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_devices[netatmo-12:34:56:26:68:92] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': None, 'name': 'Baby Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +722,8 @@ # name: test_devices[netatmo-12:34:56:26:69:0c] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -768,7 +744,6 @@ 'model_id': None, 'name': 'Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -777,8 +752,8 @@ # name: test_devices[netatmo-12:34:56:3e:c5:46] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -799,7 +774,6 @@ 'model_id': None, 'name': 'Parents Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -808,8 +782,8 @@ # name: test_devices[netatmo-12:34:56:80:00:12:ac:f2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -830,7 +804,6 @@ 'model_id': None, 'name': 'Prise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -839,8 +812,8 @@ # name: test_devices[netatmo-12:34:56:80:1c:42] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -861,7 +834,6 @@ 'model_id': None, 'name': 'Villa Outdoor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -870,8 +842,8 @@ # name: test_devices[netatmo-12:34:56:80:44:92] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -892,7 +864,6 @@ 'model_id': None, 'name': 'Villa Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -901,8 +872,8 @@ # name: test_devices[netatmo-12:34:56:80:7e:18] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -923,7 +894,6 @@ 'model_id': None, 'name': 'Villa Bathroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -932,8 +902,8 @@ # name: test_devices[netatmo-12:34:56:80:bb:26] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -954,7 +924,6 @@ 'model_id': None, 'name': 'Villa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -963,8 +932,8 @@ # name: test_devices[netatmo-12:34:56:80:c1:ea] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -985,7 +954,6 @@ 'model_id': None, 'name': 'Villa Rain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -994,8 +962,8 @@ # name: test_devices[netatmo-222452125] DeviceRegistryEntrySnapshot({ 'area_id': 'bureau', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1016,7 +984,6 @@ 'model_id': None, 'name': 'Bureau Modulate', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1025,8 +992,8 @@ # name: test_devices[netatmo-2746182631] DeviceRegistryEntrySnapshot({ 'area_id': 'livingroom', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1047,7 +1014,6 @@ 'model_id': None, 'name': 'Livingroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1056,8 +1022,8 @@ # name: test_devices[netatmo-2833524037] DeviceRegistryEntrySnapshot({ 'area_id': 'entrada', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1078,7 +1044,6 @@ 'model_id': None, 'name': 'Valve1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1087,8 +1052,8 @@ # name: test_devices[netatmo-2940411577] DeviceRegistryEntrySnapshot({ 'area_id': 'cocina', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1109,7 +1074,6 @@ 'model_id': None, 'name': 'Valve2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1118,8 +1082,8 @@ # name: test_devices[netatmo-91763b24c43d3e344f424e8b] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1140,7 +1104,6 @@ 'model_id': None, 'name': 'MYHOME', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1112,8 @@ # name: test_devices[netatmo-Home avg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1171,7 +1134,6 @@ 'model_id': None, 'name': 'Home avg', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1180,8 +1142,8 @@ # name: test_devices[netatmo-Home max] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1202,7 +1164,6 @@ 'model_id': None, 'name': 'Home max', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1211,8 +1172,8 @@ # name: test_devices[netatmo-Home min] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1233,7 +1194,6 @@ 'model_id': None, 'name': 'Home min', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 1bc1542f23db..c26e18140ab6 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -697,7 +697,7 @@ async def test_setup_component_no_devices( """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_no_data(*args, **kwargs): + async def fake_post_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index d97ac9fd641c..9fbaf006169b 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -3,9 +3,11 @@ from datetime import timedelta from functools import partial from time import time +from typing import Any from unittest.mock import AsyncMock, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from pyatmo.const import ALL_SCOPES import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +15,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud, webhook from homeassistant.components.netatmo import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.const import ( + CONF_WEBHOOK_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ( OAuth2TokenRequestReauthError, @@ -113,7 +120,7 @@ async def test_setup_component_with_config( """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -656,3 +663,89 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("platform", "entity_id", "module_id", "initial_state"), + [ + pytest.param( + "switch", "switch.prise", "12:34:56:80:00:12:ac:f2", "on", id="switch" + ), + pytest.param( + "cover", "cover.entrance_blinds", "0009999992", "closed", id="cover" + ), + pytest.param( + "fan", + "fan.centralized_ventilation_controler", + "12:34:56:00:01:01:01:b1", + "on", + id="fan", + ), + pytest.param( + "light", + "light.unknown_00_11_22_33_00_11_45_fe", + "00:11:22:33:00:11:45:fe", + "off", + id="light", + ), + pytest.param( + "button", + "button.entrance_blinds_preferred_position", + "0009999992", + STATE_UNKNOWN, + id="button", + ), + ], +) +async def test_entity_unavailable_when_device_unreachable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + platform: str, + entity_id: str, + module_id: str, + initial_state: str, +) -> None: + """Test that entities become unavailable when their device is unreachable.""" + reachable = True + + def set_reachable(payload: dict) -> None: + home = payload.get("body", {}).get("home") + if not isinstance(home, dict): + return + for module in home.get("modules", []): + if module.get("id") == module_id: + module["reachable"] = reachable + + async def fake_post(*args: Any, **kwargs: Any): + return await fake_post_request( + hass, *args, msg_callback=set_reachable, **kwargs + ) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", [platform]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == initial_state + + reachable = False + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 4d3d339e4fe7..83fe5a54607a 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -1,5 +1,6 @@ """The tests for Netatmo light.""" +from typing import Any from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion @@ -113,7 +114,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_request_no_data(*args, **kwargs): + async def fake_post_request_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index fd7b09daa4f9..259a0703653c 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,7 +1,12 @@ """The tests for Netatmo switch.""" +from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +import pyatmo +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( @@ -9,13 +14,13 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import selected_platforms, snapshot_platform_entities +from .common import fake_post_request, selected_platforms, snapshot_platform_entities -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_entity( @@ -89,3 +94,51 @@ async def test_switch_setup_and_services( ] } ) + + +@pytest.mark.parametrize( + "error", + [TimeoutError, pyatmo.ApiError], + ids=["timeout", "api_error"], +) +async def test_switch_unavailable_on_fetch_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + error: type[Exception], +) -> None: + """Test the switch becomes unavailable when the data cannot be fetched.""" + raise_error = False + + async def fake_post(*args: Any, **kwargs: Any): + if raise_error: + raise error + return await fake_post_request(hass, *args, **kwargs) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["switch"]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + switch_entity = "switch.prise" + assert hass.states.get(switch_entity).state == "on" + + raise_error = True + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(switch_entity).state == STATE_UNAVAILABLE diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index fd58e6e0002d..a201c9baded4 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.5.1', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Netgear LM1200', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'FFFFFFFFFFFFF', 'sw_version': 'EC25AFFDR07A09M4G', 'via_device_id': None, diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index d54a4e2b5e68..6309eaa183c3 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -608,7 +608,7 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "homeassistant.components.network.util.ifaddr.get_adapters", return_value=[], ): - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() with pytest.raises(HomeAssistantError): await network.async_get_source_ip(hass, MDNS_TARGET_IP) diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index d487b000d04d..4f3be1f6f48b 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -3,12 +3,40 @@ from unittest.mock import MagicMock from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + + +def device_identifiers( + device_registry: dr.DeviceRegistry, entry_id: str +) -> set[tuple[str, str]]: + """Return the identifiers of all devices for the config entry.""" + identifiers: set[tuple[str, str]] = set() + for device in dr.async_entries_for_config_entry(device_registry, entry_id): + identifiers |= device.identifiers + return identifiers + + +def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[str]: + """Return the unique ids of all entities for the config entry.""" + return { + entry.unique_id + for entry in er.async_entries_for_config_entry(entity_registry, entry_id) + } + + +def dispatch_hub_update(hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks without awaiting. + + Mirrors pynobo dispatching a single message: call this twice in a row to + reproduce buffered messages processed with no event-loop yield between them. + """ + for call in hub.register_callback.call_args_list: + call.args[0](hub) async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: """Fire the hub's registered push-update callbacks and wait for state to settle.""" - for call in hub.register_callback.call_args_list: - call.args[0](hub) + dispatch_hub_update(hub) await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 974e803740c3..0cab97fed99c 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -106,10 +106,12 @@ def mock_nobo_class( "temp_eco_c": "17", }, } - model = MagicMock() - # Direct assignment overrides MagicMock's auto-attr for `.name`. - model.name = "Panel heater" - model.has_temp_sensor = True + model = pynobo_nobo.Model( + model_id="183", + type="THERMOSTAT_FLOOR", + name="Panel heater", + has_temp_sensor=True, + ) hub.components = { "200000059091": { "serial": "200000059091", diff --git a/tests/components/nobo_hub/snapshots/test_diagnostics.ambr b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..72d02fa6421e --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'components': list([ + dict({ + 'model': dict({ + 'has_temp_sensor': True, + 'model_id': '183', + 'name': 'Panel heater', + 'requires_control_panel': False, + 'supports_comfort': False, + 'supports_eco': False, + 'type': 'THERMOSTAT_FLOOR', + }), + 'name': 'Floor sensor', + 'serial': '**REDACTED**', + 'zone_id': '1', + }), + ]), + 'entry_data': dict({ + 'ip_address': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'hub_info': dict({ + 'hardware_version': 'hw', + 'name': 'My Eco Hub', + 'serial': '**REDACTED**', + 'software_version': '115', + }), + 'overrides': dict({ + '988': dict({ + 'mode': '0', + 'target_id': '-1', + 'target_type': '0', + }), + }), + 'week_profiles': dict({ + '0': dict({ + 'name': 'Default', + 'profile': '00000', + 'week_profile_id': '0', + }), + }), + 'zones': dict({ + '1': dict({ + 'name': 'Living room', + 'temp_comfort_c': '21', + 'temp_eco_c': '17', + 'week_profile_id': '0', + 'zone_id': '1', + }), + }), + }) +# --- diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py index 2e42c5934285..a2ec77f978ea 100644 --- a/tests/components/nobo_hub/test_climate.py +++ b/tests/components/nobo_hub/test_climate.py @@ -27,16 +27,24 @@ from homeassistant.components.nobo_hub.const import ( DOMAIN, OVERRIDE_TYPE_NOW, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform CLIMATE_ENTITY = "climate.living_room_living_room" +BEDROOM_ZONE = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", +} @pytest.fixture @@ -189,14 +197,14 @@ async def test_set_preset_with_override_type_now( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_unavailable( +async def test_zone_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(CLIMATE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(CLIMATE_ENTITY) is None @pytest.mark.usefixtures("init_integration") @@ -264,3 +272,43 @@ async def test_climate_action_wraps_library_error( ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == expected_key + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_entity( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a climate entity.""" + entry_id = mock_config_entry.entry_id + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) reappears.""" + entry_id = mock_config_entry.entry_id + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index a62e84bb0a92..14fe846426a6 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -1,8 +1,8 @@ """Test the Nobø Ecohub config flow.""" -import errno from unittest.mock import AsyncMock, PropertyMock, patch +from pynobo import PynoboConnectionError import pytest from homeassistant import config_entries @@ -407,7 +407,10 @@ async def test_configure_invalid_ip_address( ("connect_outcome", "expected_error"), [ ({"return_value": False}, "cannot_connect"), - ({"side_effect": ConnectionRefusedError(61, "")}, "cannot_connect_ip"), + ( + {"side_effect": PynoboConnectionError("Failed to connect")}, + "cannot_connect_ip", + ), ], ids=["serial_mismatch", "tcp_failure"], ) @@ -420,10 +423,10 @@ async def test_configure_cannot_connect( """Connect failures map to distinct error keys; retry recovers. pynobo's async_connect_hub returns False on a successful TCP connect - followed by a handshake REJECT (serial mismatch) and raises OSError - on TCP-level failure (wrong IP / hub offline). We surface these as - cannot_connect ("check serial number") and cannot_connect_ip - ("check IP address") respectively. + followed by a handshake REJECT (serial mismatch) and raises + PynoboConnectionError on TCP-level failure (wrong IP / hub offline). + We surface these as cannot_connect ("check serial number") and + cannot_connect_ip ("check IP address") respectively. """ with patch( "homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs", @@ -818,7 +821,7 @@ async def test_reconfigure_flow_changes_ip( [ ( "192.168.1.200", - {"side_effect": ConnectionRefusedError(errno.ECONNREFUSED, "")}, + {"side_effect": PynoboConnectionError("Failed to connect")}, "cannot_connect_ip", 1, ), diff --git a/tests/components/nobo_hub/test_diagnostics.py b/tests/components/nobo_hub/test_diagnostics.py new file mode 100644 index 000000000000..2fcdeb95328c --- /dev/null +++ b/tests/components/nobo_hub/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Tests for the Nobø Ecohub diagnostics.""" + +from unittest.mock import MagicMock + +from pynobo import nobo as pynobo_nobo +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.diagnostics import REDACTED +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_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result == snapshot + + +async def test_entry_diagnostics_redacts_unknown_model_name( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """An unknown model's name embeds the serial, so it is dropped; model_id is kept.""" + mock_nobo_hub.components = { + "999000012345": { + "serial": "999000012345", + "name": "Mystery device", + "zone_id": "1", + "model": pynobo_nobo.Model( + model_id="999", + type=pynobo_nobo.Model.UNKNOWN, + name="Unknown (serial number: 999 000 012 345)", + ), + }, + } + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + component = result["components"][0] + assert component["serial"] == REDACTED + assert component["model"]["model_id"] == "999" + assert component["model"]["name"] == REDACTED diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 880aa49dd74f..f89c50b5bffc 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -3,7 +3,7 @@ import logging from unittest.mock import MagicMock -from pynobo import nobo as pynobo_nobo +from pynobo import PynoboConnectionError, nobo as pynobo_nobo import pytest from homeassistant.components.nobo_hub.const import ( @@ -14,9 +14,15 @@ from homeassistant.components.nobo_hub.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, 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 . import fire_hub_connection +from . import ( + device_identifiers, + dispatch_hub_update, + entity_unique_ids, + fire_hub_connection, + fire_hub_update, +) from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry @@ -55,7 +61,7 @@ async def test_setup_rediscovery_updates_ip( """A failed direct connect falls back to rediscovery and persists the new IP.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub, mock_nobo_class.return_value] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -77,7 +83,7 @@ async def test_setup_retries_when_rediscovery_finds_nothing( """Setup retries when stored IP fails and rediscovery is empty.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub] mock_nobo_class.async_discover_hubs.return_value = set() @@ -100,9 +106,9 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( """Setup retries when both stored and rediscovered IPs fail.""" mock_config_entry.add_to_hass(hass) first_failing_hub = MagicMock(spec=pynobo_nobo) - first_failing_hub.connect.side_effect = OSError("Unreachable") + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") second_failing_hub = MagicMock(spec=pynobo_nobo) - second_failing_hub.connect.side_effect = OSError("Unreachable") + second_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -117,6 +123,27 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( } +async def test_setup_does_not_catch_plain_os_error_on_rediscovered_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, +) -> None: + """A plain OSError from the rediscovered IP is not caught by the fallback.""" + mock_config_entry.add_to_hass(hass) + first_failing_hub = MagicMock(spec=pynobo_nobo) + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") + second_failing_hub = MagicMock(spec=pynobo_nobo) + second_failing_hub.connect.side_effect = OSError("boom") + mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] + mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_nobo_class.call_count == 2 + + @pytest.mark.parametrize( ("stored_options", "expected_options"), [ @@ -324,3 +351,152 @@ async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( await fire_hub_connection(hass, mock_nobo_hub, True) assert hass.states.get(entity).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_removed_zone_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a zone on the hub removes its device but keeps the hub device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, f"{SERIAL}:1") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.zones["1"] + await fire_hub_update(hass, mock_nobo_hub) + + identifiers = device_identifiers(device_registry, entry_id) + assert (DOMAIN, f"{SERIAL}:1") not in identifiers + assert (DOMAIN, SERIAL) in identifiers + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_removed_component_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a temperature-sensor component on the hub removes its device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, "200000059091") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.components["200000059091"] + await fire_hub_update(hass, mock_nobo_hub) + + assert (DOMAIN, "200000059091") not in device_identifiers(device_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_disconnected_hub_does_not_remove_devices( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices are retained when topology looks empty because the hub is disconnected.""" + entry_id = mock_config_entry.entry_id + before = device_identifiers(device_registry, entry_id) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + assert device_identifiers(device_registry, entry_id) == before + + +@pytest.mark.parametrize( + "platforms", + [[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_disconnect_does_not_readd_entities_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale empty topology while disconnected must not forget known ids. + + Otherwise the reconcile would clear the known-id sets and re-add every + entity on reconnect, colliding with the still-registered unique ids. + """ + saved_zones = dict(mock_nobo_hub.zones) + saved_components = dict(mock_nobo_hub.components) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + mock_nobo_hub.connected = True + mock_nobo_hub.zones.update(saved_zones) + mock_nobo_hub.components.update(saved_components) + await fire_hub_update(hass, mock_nobo_hub) + + assert "already exists" not in caplog.text + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_buffered_remove_then_readd_same_id( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A buffered remove + same-id re-add (no event-loop yield between) re-registers cleanly. + + pynobo can process a delete and an id-reusing add back-to-back before the + loop yields (buffered messages), so synchronous device removal must fully + deregister the old entity before the re-add, or the add collides with the + still-registered unique id. + """ + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + # Remove then re-add the same id with no await (no event-loop yield) between. + del mock_nobo_hub.zones["2"] + dispatch_hub_update(mock_nobo_hub) + mock_nobo_hub.zones["2"] = zone + dispatch_hub_update(mock_nobo_hub) + await hass.async_block_till_done() + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + assert "already exists" not in caplog.text + + +@pytest.mark.usefixtures("mock_nobo_class") +async def test_stale_device_pruned_at_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """A device for a zone removed while Home Assistant was down is pruned at setup.""" + mock_config_entry.add_to_hass(hass) + stale_device = (DOMAIN, f"{SERIAL}:99") + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={stale_device}, + ) + entry_id = mock_config_entry.entry_id + assert stale_device in device_identifiers(device_registry, entry_id) + + assert await hass.config_entries.async_setup(entry_id) + await hass.async_block_till_done() + + assert stale_device not in device_identifiers(device_registry, entry_id) diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py index 3c6871254655..31c401543cd9 100644 --- a/tests/components/nobo_hub/test_select.py +++ b/tests/components/nobo_hub/test_select.py @@ -12,12 +12,13 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform @@ -153,11 +154,64 @@ async def test_week_profile_push_update( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_week_profile_unavailable( +async def test_zone_removed_removes_week_profile_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(PROFILE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(PROFILE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) restores its selector.""" + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a week-profile selector.""" + entry_id = mock_config_entry.entry_id + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + await fire_hub_update(hass, mock_nobo_hub) + + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_sensor.py b/tests/components/nobo_hub/test_sensor.py index 340a06117561..12fad4c74a7b 100644 --- a/tests/components/nobo_hub/test_sensor.py +++ b/tests/components/nobo_hub/test_sensor.py @@ -5,11 +5,11 @@ from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update from tests.common import MockConfigEntry, snapshot_platform @@ -58,11 +58,75 @@ async def test_temperature_push_update( @pytest.mark.usefixtures("init_integration") -async def test_component_removed_marks_unavailable( +async def test_component_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A component removed via the Nobø app must not crash and goes unavailable.""" + """Removing a component via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.components.pop("200000059091") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(TEMPERATURE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_component_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A component removed and re-added under the same serial (the hub reuses serials) reappears.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = True + component = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.components[serial] + await fire_hub_update(hass, mock_nobo_hub) + assert serial not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.parametrize( + ("has_temp_sensor", "present"), + [(True, True), (False, False)], + ids=["temp_sensor", "no_temp_sensor"], +) +@pytest.mark.usefixtures("init_integration") +async def test_new_component_added( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + has_temp_sensor: bool, + present: bool, +) -> None: + """A component yields a sensor only when it has a temperature sensor.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = has_temp_sensor + mock_nobo_hub.components[serial] = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + await fire_hub_update(hass, mock_nobo_hub) + + assert (serial in entity_unique_ids(entity_registry, entry_id)) is present diff --git a/tests/components/nrgkick/snapshots/test_init.ambr b/tests/components/nrgkick/snapshots/test_init.ambr index ef360a6b468e..4723836a404f 100644 --- a/tests/components/nrgkick/snapshots/test_init.ambr +++ b/tests/components/nrgkick/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.100', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'NRGkick Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TEST123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index f920b064f0bc..fbb9697e84ed 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[device_connections] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Connections', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info[device_spelling_bee] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Spelling Bee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_info[device_wordle] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wordle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index dc49f5f40424..3614096e5390 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Ohme Home Pro', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'chargerid', 'sw_version': 'v2.65', 'via_device_id': None, diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index d16d4fd4c0b4..340d1dcb7249 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -732,7 +732,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index c3d8d92a9d20..6efcdfed0b6a 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[ondilo_ico-W1122333044455] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Pool 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'W1122333044455', 'sw_version': '1.7.1-stable', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[ondilo_ico-W2233304445566] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Pool 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'W2233304445566', 'sw_version': '1.7.1-stable', 'via_device_id': None, diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr index 2573c34e1fad..f0aad4adec7c 100644 --- a/tests/components/onedrive/snapshots/test_init.ambr +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://onedrive.live.com/?id=root&cid=mock_drive_id', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'My Drive', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index d7e0d711c252..4209470f76da 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_registry[01.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'DS2401', 'name': '01.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_registry[05.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_registry[10.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_registry[12.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_registry[1D.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': , @@ -157,8 +152,8 @@ # name: test_registry[1F.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_registry[20.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': 'DS2450', 'name': '20.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_registry[26.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_registry[28.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_registry[28.222222222222-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222222', 'sw_version': '3.2', 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_registry[28.222222222223-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222223', 'sw_version': '3.2', 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_registry[29.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_registry[30.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_registry[3A.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_registry[3B.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_registry[42.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_registry[7E.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_registry[7E.222222222222-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222222', 'sw_version': '3.2', 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_registry[7E.333333333333-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': 'EDS0065', 'name': '7E.333333333333', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '333333333333', 'sw_version': '3.2', 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_registry[A6.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': 'DS2438', 'name': 'A6.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_registry[EF.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_registry[EF.111111111112-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111112', 'sw_version': '3.2', 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_registry[EF.111111111113-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111113', 'sw_version': '3.2', 'via_device_id': None, diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 2839fe10a0ca..22a18743394b 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -92,7 +92,7 @@ def mock_config_entry( @pytest.fixture -def mock_config_entry_with_assist( +async def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -101,11 +101,12 @@ def mock_config_entry_with_assist( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) + await hass.async_block_till_done() return mock_config_entry @pytest.fixture -def mock_config_entry_with_reasoning_model( +async def mock_config_entry_with_reasoning_model( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -114,6 +115,7 @@ def mock_config_entry_with_reasoning_model( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"}, ) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index caf16e6990da..dac962c59c7f 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -297,6 +297,27 @@ }), ]) # --- +# name: test_model_args[subentry_options0] + dict({ + 'include': list([ + 'reasoning.encrypted_content', + ]), + 'max_output_tokens': 3000, + 'model': 'gpt-5.6-sol', + 'prompt_cache_retention': '24h', + 'reasoning': dict({ + 'effort': 'low', + 'mode': 'pro', + 'summary': 'auto', + }), + 'service_tier': 'auto', + 'store': False, + 'stream': True, + 'text': dict({ + 'verbosity': 'medium', + }), + }) +# --- # name: test_web_search[False] list([ dict({ diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index f5006ac979f1..87d719557790 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[mock_conversation_subentry_data0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18,7 +18,6 @@ 'model_id': None, 'name': 'OpenAI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -27,8 +26,8 @@ # name: test_devices[mock_conversation_subentry_data1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -43,7 +42,6 @@ 'model_id': None, 'name': 'OpenAI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index a3cb3999e916..d83c1f263d1e 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.openai_conversation.const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -273,6 +274,7 @@ async def test_subentry_unsupported_model( ("gpt-5.4-pro", ["medium", "high", "xhigh"]), ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.5-pro", ["medium", "high", "xhigh"]), + ("gpt-5.6", ["none", "low", "medium", "high", "xhigh", "max"]), ], ) async def test_subentry_reasoning_effort_list( @@ -466,6 +468,8 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch( @pytest.mark.parametrize( ("model", "service_tier_options"), [ + ("gpt-5.6", ["auto", "flex", "default", "priority"]), + ("gpt-5.5", ["auto", "flex", "default", "priority"]), ("gpt-5.4", ["auto", "flex", "default", "priority"]), ("gpt-5.4-pro", ["auto", "flex", "default", "priority"]), ("gpt-5.2", ["auto", "flex", "default", "priority"]), @@ -817,12 +821,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, { - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -831,17 +835,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_STORE_RESPONSES: False, - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -850,6 +855,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), # Test that old options are removed after reconfiguration @@ -966,7 +972,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", @@ -974,6 +980,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_SERVICE_TIER: "flex", CONF_CODE_INTERPRETER: True, CONF_VERBOSITY: "medium", + CONF_PRO_MODE: True, }, ( { diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index d1e7ec528bd9..933e57bc7d1b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -21,6 +21,7 @@ from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_CODE_INTERPRETER, + CONF_PRO_MODE, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, CONF_STORE_RESPONSES, @@ -817,3 +818,46 @@ async def test_flex_tier_retry( ) assert mock_create_stream.mock_calls[0][2]["service_tier"] == "flex" assert mock_create_stream.mock_calls[1][2]["service_tier"] == "default" + + +@pytest.mark.parametrize( + "subentry_options", [{CONF_CHAT_MODEL: "gpt-5.6-sol", CONF_PRO_MODE: True}] +) +async def test_model_args( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, + subentry_options: dict, +) -> None: + """Test model arguments for various configuration.""" + + subentry = next( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data=subentry_options, + ) + await hass.async_block_till_done() + + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi!", output_index=0), + ] + + result = await conversation.async_converse( + hass, + "Hello", + None, + Context(), + agent_id="conversation.openai_conversation", + ) + + model_args = mock_create_stream.call_args.kwargs.copy() + model_args.pop("input") + assert model_args.pop("user") == result.conversation_id + assert model_args == snapshot diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index f8d85e353e74..73dd2a79c5f4 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1278,7 +1278,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py index 757a10d22a1c..593d9101e1fc 100644 --- a/tests/components/oralb/__init__.py +++ b/tests/components/oralb/__init__.py @@ -37,6 +37,28 @@ ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO = BluetoothServiceInfo( + name="Oral-B Toothbrush", + address="78:DB:2F:C2:48:BE", + rssi=-63, + # running, 6 sectors, currently in sector 5 + manufacturer_data={220: b"\x062\x0c\x03\x00\x00\x1e\x00\x05\x0a\x06"}, + service_uuids=[], + service_data={}, + source="local", +) + +ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO = BluetoothServiceInfo( + name="Oral-B Toothbrush", + address="78:DB:2F:C2:48:BE", + rssi=-63, + # running, 6 sectors, "last sector" sentinel (7) resolves to sector 6 + manufacturer_data={220: b"\x062\x0c\x03\x00\x00\x28\x00\x07\x0a\x06"}, + service_uuids=[], + service_data={}, + source="local", +) + ORALB_IO_SERIES_6_SERVICE_INFO = BluetoothServiceInfoBleak( name="Oral-B Toothbrush", address="B0:D2:78:20:1D:CF", diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index a6b51694a188..c3a40a3bd8e7 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.bluetooth import ( async_address_present, ) from homeassistant.components.oralb.const import DOMAIN +from homeassistant.components.sensor import ATTR_OPTIONS from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -17,6 +18,8 @@ from homeassistant.util import dt as dt_util from . import ( ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_IO_SERIES_6_SERVICE_INFO, + ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO, + ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO, ORALB_SERVICE_INFO, ) @@ -141,6 +144,50 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sector_sensor_six_sectors(hass: HomeAssistant) -> None: + """Test the sector sensor while brushing with a six sector routine.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info(hass, ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO) + await hass.async_block_till_done() + + sector_sensor = hass.states.get("sensor.io_series_48be_sector") + assert sector_sensor.state == "sector_5" + assert sector_sensor.attributes[ATTR_OPTIONS] == [ + "no_sector", + "sector_1", + "sector_2", + "sector_3", + "sector_4", + "sector_5", + "sector_6", + "sector_7", + ] + + number_of_sectors_sensor = hass.states.get( + "sensor.io_series_48be_number_of_sectors" + ) + assert number_of_sectors_sensor.state == "6" + + # The "last sector" sentinel resolves to the sector count (sector 6) + inject_bluetooth_service_info(hass, ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO) + await hass.async_block_till_done() + + sector_sensor = hass.states.get("sensor.io_series_48be_sector") + assert sector_sensor.state == "sector_6" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_sensors_battery(hass: HomeAssistant) -> None: """Test receiving battery percentage.""" entry = MockConfigEntry( diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json index 9f6ed2f2a18e..6d18e6b9eecd 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json @@ -544,6 +544,73 @@ "type": 1, "oid": "c198bcdd-8b8b-4dc6-a2b0-f86f7dc7c001", "uiClass": "VenetianBlind" + }, + { + "creationTime": 1613676720000, + "lastUpdateTime": 1613676720000, + "label": "Living Room Screen", + "deviceURL": "rts://1234-1234-6362/16718220", + "shortcut": false, + "controllableName": "rts:GenericRTSComponent", + "definition": { + "commands": [ + { + "commandName": "down", + "nparams": 1 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "rest", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 1 + }, + { + "commandName": "test", + "nparams": 0 + }, + { + "commandName": "up", + "nparams": 1 + }, + { + "commandName": "openConfiguration", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [ + { + "value": "0", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "RTSGeneric", + "uiProfiles": ["UpDown"], + "uiClass": "Generic", + "qualifiedName": "rts:GenericRTSComponent", + "type": "ACTUATOR" + }, + "states": [], + "attributes": [ + { + "name": "rts:diy", + "type": 6, + "value": true + } + ], + "available": true, + "enabled": true, + "placeOID": "6133b4a0-f514-4553-b635-d1b7beb7e7b2", + "widget": "RTSGeneric", + "type": 1, + "oid": "97f85fd1-53f7-4cdf-8c73-9b2c172dbd62", + "uiClass": "Generic" } ], "zones": [], diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json index 4c127ecb54d5..5582c6aff293 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json @@ -3483,6 +3483,1116 @@ "widget": "ZigbeeStack", "oid": "08fa5c0f-95ba-410a-8a66-4cb55c0d508c", "uiClass": "ProtocolGateway" + }, + { + "label": "Thermostat", + "uiClass": "HeatingSystem", + "deviceURL": "io://1234-5678-5010/386310#1", + "shortcut": false, + "controllableName": "io:HeatingThermostatIOComponent", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setTimeProgramById", + "nparams": 2 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "exitDerogation", + "nparams": 0 + }, + { + "commandName": "setAllModeTemperatures", + "nparams": 4 + }, + { + "commandName": "setDerogation", + "nparams": 2 + }, + { + "commandName": "setThermostatSettings", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:ActiveTimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:BatteryLevelState" + }, + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ComfortRoomTemperatureState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:DerogatedTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationEndDateTimeState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationStartDateTimeState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:EcoTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:FrostProtectionRoomTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:HeatingAnticipationState" + }, + { + "type": "DataState", + "qualifiedName": "core:MaxSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:MinSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedValveState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:OpenWindowDetectionActivationState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:PermanentDisplayState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetRoomTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureHysteresisState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureOffsetConfigurationState" + }, + { + "type": "DiscreteState", + "values": ["cooling", "heating", "heatingAndCooling"], + "qualifiedName": "core:ThermalConfigurationState" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram1State" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram2State" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:AwayModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:CurrentHeatingModeState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:DerogationHeatingModeState" + }, + { + "type": "DiscreteState", + "values": ["date", "furtherNotice", "nextMode"], + "qualifiedName": "io:DerogationTypeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:GeofencingModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "io:LockKeyActivationState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:ManualModeTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:OpenWindowDetectedTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "adjustment", + "finished", + "full_closed", + "full_open", + "pairing", + "reset" + ], + "qualifiedName": "io:ValveInstallationModeState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "ThermostatHeatingTemperatureInterface", + "uiProfiles": ["ThermostatTargetReader"], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "io:HeatingThermostatIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "io:DerogationTypeState", + "type": 3, + "value": "further_notice" + }, + { + "name": "io:DerogationHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:DerogatedTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "io:ManualModeTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:DerogationStartDateTimeState", + "type": 5, + "value": 1779390409000 + }, + { + "name": "core:DerogationEndDateTimeState", + "type": 5, + "value": 4294967295000 + }, + { + "name": "core:ComfortRoomTemperatureState", + "type": 2, + "value": 21.0 + }, + { + "name": "io:AwayModeTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "core:EcoTargetTemperatureState", + "type": 2, + "value": 19.0 + }, + { + "name": "io:GeofencingModeTargetTemperatureState", + "type": 2, + "value": 20.0 + }, + { + "name": "core:FrostProtectionRoomTemperatureState", + "type": 2, + "value": 8.0 + }, + { + "name": "io:OpenWindowDetectedTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "io:ValveInstallationModeState", + "type": 3, + "value": "finished" + }, + { + "name": "core:BatteryLevelState", + "type": 2, + "value": 100.0 + }, + { + "name": "core:TimeProgram1State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:TimeProgram2State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:OpenClosedValveState", + "type": 3, + "value": "open" + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "io:CurrentHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:TargetRoomTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:OpenWindowDetectionActivationState", + "type": 3, + "value": "active" + }, + { + "name": "io:LockKeyActivationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:PermanentDisplayState", + "type": 3, + "value": "enable" + }, + { + "name": "core:ThermalConfigurationState", + "type": 3, + "value": "heating" + }, + { + "name": "core:HeatingAnticipationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:ActiveTimeProgramState", + "type": 3, + "value": "none" + }, + { + "name": "core:MaxSetpointState", + "type": 2, + "value": 26.0 + }, + { + "name": "core:MinSetpointState", + "type": 2, + "value": 5.0 + }, + { + "name": "core:TemperatureOffsetConfigurationState", + "type": 2, + "value": 0.0 + }, + { + "name": "core:TargetTemperatureHysteresisState", + "type": 2, + "value": 0.3 + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "d241a2c8-713a-428a-9911-0f8226af676e", + "widget": "ThermostatHeatingTemperatureInterface", + "type": 1 + }, + { + "label": "Thermostat Temperature", + "uiClass": "TemperatureSensor", + "deviceURL": "io://1234-5678-5010/386310#2", + "shortcut": false, + "controllableName": "io:TemperatureIOSystemSensor", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureState" + } + ], + "dataProperties": [], + "widgetName": "TemperatureSensor", + "uiProfiles": ["Temperature"], + "uiClass": "TemperatureSensor", + "qualifiedName": "io:TemperatureIOSystemSensor", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "core:TemperatureState", + "type": 2, + "value": 26.6 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155003A14" + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:MaxSensedValue", + "type": 2, + "value": 655.35 + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "battery" + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "c32eb2cd-06de-4827-95fb-51ae49acf467", + "widget": "TemperatureSensor", + "type": 2 } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr index cef1c9b44662..91e4b14aa5d9 100644 --- a/tests/components/overkiz/snapshots/test_climate.ambr +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -656,3 +656,85 @@ 'state': 'heat_cool', }) # --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.study_thermostat', + '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': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-5010/386310#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 26.6, + : 'Thermostat', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'manual', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 16.5, + }), + 'context': , + 'entity_id': 'climate.study_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index e8b5e5d604a0..f2bf58ab2ec1 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -485,6 +485,59 @@ 'state': 'unknown', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_living_room_screen-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.palm_court_living_room_screen', + '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': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'rts://1234-1234-6362/16718220', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_living_room_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'Living Room Screen', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.palm_court_living_room_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_office_venetian_blind-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index 74a47ca41ed0..8d11067804c8 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -13,6 +13,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, HVACAction, HVACMode, ) @@ -22,6 +23,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration from .helpers import ( + assert_command_call, async_deliver_events, device_available_event, device_removed_event, @@ -55,11 +57,18 @@ YUTAKI_ZONE_2 = FixtureDevice( "modbus://1234-5678-2284/5416194/1#3", "climate.somfy_tahoma_switch_yutaki_zone_2", ) +# io:HeatingThermostatIOComponent +THERMOSTAT_HEATING = FixtureDevice( + "setup/cloud_somfy_tahoma_switch_sc_europe.json", + "io://1234-5678-5010/386310#1", + "climate.study_thermostat", +) SNAPSHOT_FIXTURES = [ VALVE, COZYTOUCH, YUTAKI_ZONE_1, + THERMOSTAT_HEATING, ] @@ -178,3 +187,61 @@ async def test_hitachi_air_to_water_heating_zone_2( assert zone_2.state == HVACMode.AUTO assert zone_2.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 assert zone_2.attributes[ATTR_TEMPERATURE] == 21.0 + + +async def test_thermostat_heating_set_temperature( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test setting a temperature issues setDerogation, not setComfortTemperature.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_TEMPERATURE: 20.0}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=[20.0, "further_notice"], + ) + + +@pytest.mark.parametrize( + ("preset_mode", "parameters"), + [ + pytest.param("away", ["away", "further_notice"], id="away"), + pytest.param("comfort", ["comfort", "further_notice"], id="comfort"), + pytest.param("eco", ["eco", "further_notice"], id="eco"), + # Manual re-sends the current temperature to enter the derogation + pytest.param("manual", [26.6, "further_notice"], id="manual"), + ], +) +async def test_thermostat_heating_set_preset_mode( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, + preset_mode: str, + parameters: list[str | float], +) -> None: + """Test selecting a preset issues setDerogation with the mapped parameter.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=parameters, + ) diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 76633f29d74c..eaf0d21845df 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -122,6 +122,12 @@ UP_DOWN_SHEER_SCREEN = FixtureDevice( "rts://1234-1234-6362/16753206", "cover.palm_court_kitchen_sheer_screen", ) +# RTSGeneric only exposes raw up/down/stop commands (no open/close) +RTS_GENERIC = FixtureDevice( + "setup/cloud_somfy_connexoon_rts_asia.json", + "rts://1234-1234-6362/16718220", + "cover.palm_court_living_room_screen", +) DISCRETE_GARAGE_DOOR = FixtureDevice( "setup/local_somfy_tahoma_v2_europe.json", "io://1234-5678-3293/12745774", @@ -276,6 +282,7 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (UP_DOWN_SHEER_SCREEN, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (RTS_GENERIC, SERVICE_OPEN_COVER, "up", None, CoverState.OPENING), ( DYNAMIC_VENETIAN_BLIND, SERVICE_OPEN_COVER, @@ -334,6 +341,7 @@ async def test_cover_entities_snapshot( CoverState.CLOSING, ), (UP_DOWN_SHEER_SCREEN, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + (RTS_GENERIC, SERVICE_CLOSE_COVER, "down", None, CoverState.CLOSING), ( DYNAMIC_VENETIAN_BLIND, SERVICE_CLOSE_COVER, @@ -396,6 +404,7 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), (UP_DOWN_SHEER_SCREEN, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), + (RTS_GENERIC, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), ( UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER_TILT, @@ -459,6 +468,7 @@ async def test_cover_entities_snapshot( "open-tilt-only-venetian-blind", "open-venetian-blind-rts", "open-sheer-screen-rts", + "open-rts-generic", "open-dynamic-venetian-blind", "close-roller-shutter", "close-awning", @@ -479,6 +489,7 @@ async def test_cover_entities_snapshot( "close-tilt-only-venetian-blind", "close-venetian-blind-rts", "close-sheer-screen-rts", + "close-rts-generic", "close-dynamic-venetian-blind", "stop-roller-shutter", "stop-awning", @@ -499,6 +510,7 @@ async def test_cover_entities_snapshot( "stop-tilt-tilt-only-venetian-blind", "stop-venetian-blind-rts", "stop-sheer-screen-rts", + "stop-rts-generic", "open-tilt-venetian-blind-rts", "close-tilt-venetian-blind-rts", "stop-tilt-venetian-blind-rts", diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index 5435aff659c5..8c9d45c99f58 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -67,6 +67,7 @@ def mock_overseerr_client() -> Generator[AsyncMock]: client.get_tv_details.return_value = TVDetails.from_json( load_fixture("tv.json", DOMAIN) ) + client.search.return_value = [] yield client diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index f861ccaa9ed0..6c8778536a63 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://overseerr.test', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Overseerr', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 39df5760693d..07279dff3efd 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -1,18 +1,29 @@ """Tests for the Overseerr services.""" +import dataclasses from unittest.mock import AsyncMock import pytest from python_overseerr import OverseerrConnectionError +from python_overseerr.models import MediaType from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, ATTR_REQUESTED_BY, + ATTR_SEASONS, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) -from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.components.overseerr.services import ( + SERVICE_GET_REQUESTS, + SERVICE_REQUEST_MEDIA, + SERVICE_SEARCH_MEDIA, + parse_seasons_input, +) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -75,6 +86,65 @@ async def test_service_get_requests_no_meta( assert request["media"] == {} +async def test_service_search_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the search_media service.""" + # Mock the search method + mock_overseerr_client.search.return_value = [] + + await setup_integration(hass, mock_config_entry) + + # Test with a query containing spaces + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_QUERY: "test query with spaces", + }, + blocking=True, + return_response=True, + ) + assert response == {"results": []} + mock_overseerr_client.search.assert_called_once_with("test query with spaces") + + +async def test_service_request_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the request_media service.""" + + # Mock the create request method + @dataclasses.dataclass + class RequestWithMediaMock: + tmdb_id: str = "123456789" + media_type: MediaType = MediaType.TV + + mock_overseerr_client.create_request.return_value = RequestWithMediaMock() + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_REQUEST_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEDIA_TYPE: "tv", + ATTR_MEDIA_ID: "123456789", + ATTR_SEASONS: "1", + }, + blocking=True, + return_response=True, + ) + + assert response == {"request": {"media_type": MediaType.TV, "tmdb_id": "123456789"}} + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ @@ -85,7 +155,23 @@ async def test_service_get_requests_no_meta( OverseerrConnectionError("Timeout"), HomeAssistantError, "Error connecting to the Seerr instance: Timeout", - ) + ), + ( + SERVICE_SEARCH_MEDIA, + {ATTR_QUERY: "test"}, + "search", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + "create_request", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), ], ) async def test_services_connection_error( @@ -119,6 +205,11 @@ async def test_services_connection_error( ("service", "payload"), [ (SERVICE_GET_REQUESTS, {}), + (SERVICE_SEARCH_MEDIA, {ATTR_QUERY: "test"}), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + ), ], ) async def test_service_entry_availability( @@ -154,3 +245,29 @@ async def test_service_entry_availability( return_response=True, ) assert err.value.translation_key == "service_config_entry_not_found" + + +@pytest.mark.parametrize( + ("seasons_input", "expected_seasons"), + [ + ("1", [1]), + ("1,", [1]), + ("1,2,3", [1, 2, 3]), + ("1, 2, 3", [1, 2, 3]), + (" 1 , 2, 3 ", [1, 2, 3]), + ("[1]", [1]), + ("[1,2,3]", [1, 2, 3]), + ("[ 1 , 2 , 3]", [1, 2, 3]), + ("", "all"), + (" ", "all"), + (None, "all"), + ("all", "all"), + ("Not a valid input", "all"), + ("-", "all"), + ], +) +def test_parse_seasons_input( + seasons_input: str | None, expected_seasons: list[int] | str +) -> None: + """Test that all inputs are parsed correctly.""" + assert expected_seasons == parse_seasons_input(seasons_input) diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index 3fca1d851ce0..f1bf893c7483 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Stove', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0.0', 'via_device_id': None, diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index 21edc32c6290..207bf037b748 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_peblar_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.127', 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': '6004-2300-8002', 'name': 'Peblar EV Charger', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '23-45-A4O-MOF', 'sw_version': '1.6.1+1+WL-1', 'via_device_id': None, diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 569d65df3872..fac10ec491bf 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -1,6 +1,7 @@ """Conftest for Picnic tests.""" from collections.abc import Awaitable, Callable +from datetime import timedelta import json from unittest.mock import MagicMock, patch @@ -9,12 +10,18 @@ import pytest from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, load_fixture from tests.typing import WebSocketGenerator ENTITY_ID = "todo.mock_title_shopping_cart" +SetupDeliveryFixture = Callable[ + [str, tuple[timedelta, timedelta] | None, tuple[timedelta, timedelta]], + Awaitable[dict], +] + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -37,13 +44,48 @@ def mock_picnic_api(): client.session.auth_token = "3q29fpwhulzes" client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) - client.get_deliveries.return_value = json.loads( - load_fixture("picnic/delivery.json") - ) + client.get_deliveries.return_value = [ + json.loads(load_fixture("picnic/delivery.json")) + ] client.get_delivery_position.return_value = {} yield client +@pytest.fixture +def setup_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> SetupDeliveryFixture: + """Return a factory to set up the integration with the delivery in a given state.""" + + async def _setup( + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + ) -> dict: + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = status + delivery["delivery_time"] = None + # eta2 is the API's field name for the route-planning ETA + delivery["eta2"] = eta2 and { + "start": (dt_util.utcnow() + eta2[0]).isoformat(), + "end": (dt_util.utcnow() + eta2[1]).isoformat(), + } + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + slot_window[0] + ).isoformat() + delivery["slot"]["window_end"] = (dt_util.utcnow() + slot_window[1]).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return delivery + + return _setup + + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock diff --git a/tests/components/picnic/test_coordinator.py b/tests/components/picnic/test_coordinator.py index 9279ec07b497..209fcedd29f8 100644 --- a/tests/components/picnic/test_coordinator.py +++ b/tests/components/picnic/test_coordinator.py @@ -1,11 +1,22 @@ """Tests for the Picnic coordinator.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.picnic.const import ( + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from .conftest import SetupDeliveryFixture + +from tests.common import MockConfigEntry, async_fire_time_changed async def test_timeout_failed_with_retry( @@ -21,3 +32,149 @@ async def test_timeout_failed_with_retry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("status", "eta2", "slot_window", "expected_interval"), + [ + pytest.param( + "COMPLETED", + None, + (timedelta(hours=-2), timedelta(hours=-1)), + DEFAULT_UPDATE_INTERVAL, + id="no_undelivered_order", + ), + pytest.param( + "CURRENT", + (timedelta(days=2), timedelta(days=2, hours=1)), + (timedelta(days=2), timedelta(days=2, hours=1)), + DEFAULT_UPDATE_INTERVAL, + id="delivery_days_away", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + DELIVERY_UPDATE_INTERVAL, + id="delivery_under_way", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=40), timedelta(minutes=60)), + (timedelta(minutes=40), timedelta(minutes=60)), + timedelta(minutes=10), + id="next_poll_capped_at_window_start", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + DELIVERY_UPDATE_INTERVAL, + id="next_poll_never_sooner_than_delivery_interval", + ), + pytest.param( + "CURRENT", + (timedelta(hours=-4), timedelta(hours=-3)), + (timedelta(hours=-4), timedelta(hours=-3)), + DEFAULT_UPDATE_INTERVAL, + id="long_past_window_still_current", + ), + pytest.param( + "CURRENT", + None, + (timedelta(minutes=10), timedelta(minutes=70)), + DELIVERY_UPDATE_INTERVAL, + id="slot_window_fallback_without_eta", + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_interval( + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + expected_interval: timedelta, +) -> None: + """Test the update interval for the various delivery states.""" + await setup_delivery(status, eta2, slot_window) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == expected_interval + + +@pytest.mark.usefixtures("freezer") +async def test_update_interval_with_malformed_eta( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> None: + """Test that a malformed ETA falls back to the slot window.""" + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = "CURRENT" + delivery["delivery_time"] = None + delivery["eta2"] = {"start": "malformed", "end": "malformed"} + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + timedelta(minutes=10) + ).isoformat() + delivery["slot"]["window_end"] = ( + dt_util.utcnow() + timedelta(minutes=70) + ).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_after_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the update interval returns to the default once delivered.""" + delivery = await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + delivery["status"] = "COMPLETED" + freezer.tick(DELIVERY_UPDATE_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_when_refresh_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that failed refreshes still relax the interval past the window.""" + await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + mock_picnic_api.get_cart.return_value = None + freezer.tick(timedelta(hours=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.last_update_success is False + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL diff --git a/tests/components/pooldose/snapshots/test_init.ambr b/tests/components/pooldose/snapshots/test_init.ambr index b4a76f55c83b..627c692efc2b 100644 --- a/tests/components/pooldose/snapshots/test_init.ambr +++ b/tests/components/pooldose/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.100/index.html', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'PDPR1H1HAW100', 'name': 'Pool Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TEST123456789', 'sw_version': '1.30 (SW v2.10, API v1)', 'via_device_id': None, diff --git a/tests/components/portainer/snapshots/test_init.ambr b/tests/components/portainer/snapshots/test_init.ambr index 47eceb891301..237c4b507149 100644 --- a/tests/components/portainer/snapshots/test_init.ambr +++ b/tests/components/portainer/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/dashboard', 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'my-environment', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ff31facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': None, 'name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': None, 'name': 'focused_einstein', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -112,15 +109,14 @@ 'model_id': None, 'name': 'funny_chatelet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -141,15 +137,14 @@ 'model_id': None, 'name': 'practical_morse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -170,15 +165,14 @@ 'model_id': None, 'name': 'serene_banach', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/webstack', 'connections': set({ }), @@ -199,15 +193,14 @@ 'model_id': None, 'name': 'webstack', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/dashy', 'connections': set({ }), @@ -228,15 +221,14 @@ 'model_id': None, 'name': 'dashy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -257,15 +249,14 @@ 'model_id': None, 'name': 'stoic_turing', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/dashy_config', 'connections': set({ }), @@ -286,15 +277,14 @@ 'model_id': None, 'name': 'dashy_config', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/db_data', 'connections': set({ }), @@ -315,15 +305,14 @@ 'model_id': None, 'name': 'db_data', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/myvolume', 'connections': set({ }), @@ -344,7 +333,6 @@ 'model_id': None, 'name': 'myvolume', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr index c63338ea4000..8586f88d363f 100644 --- a/tests/components/portainer/snapshots/test_sensor.ambr +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -458,11 +458,12 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -502,11 +503,12 @@ : 'enum', : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 State', : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -985,11 +987,12 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -1029,11 +1032,12 @@ : 'enum', : 'focused_einstein State', : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -1339,11 +1343,12 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -1383,11 +1388,12 @@ : 'enum', : 'funny_chatelet State', : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -2788,11 +2794,12 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -2832,11 +2839,12 @@ : 'enum', : 'practical_morse State', : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -3142,11 +3150,12 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -3186,11 +3195,12 @@ : 'enum', : 'serene_banach State', : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -3496,11 +3506,12 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), @@ -3540,11 +3551,12 @@ : 'enum', : 'stoic_turing State', : list([ - 'running', - 'exited', - 'paused', - 'restarting', 'created', + 'restarting', + 'running', + 'removing', + 'paused', + 'exited', 'dead', ]), }), diff --git a/tests/components/portainer/snapshots/test_update.ambr b/tests/components/portainer/snapshots/test_update.ambr index 7f3bb09835ff..9a75bddda14c 100644 --- a/tests/components/portainer/snapshots/test_update.ambr +++ b/tests/components/portainer/snapshots/test_update.ambr @@ -30,7 +30,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_image_update', 'unit_of_measurement': None, @@ -49,7 +49,7 @@ : None, : None, : None, - : , + : , : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', : None, }), @@ -92,7 +92,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_focused_einstein_container_image_update', 'unit_of_measurement': None, @@ -111,7 +111,7 @@ : None, : None, : None, - : , + : , : 'focused_einstein', : None, }), @@ -154,7 +154,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_funny_chatelet_container_image_update', 'unit_of_measurement': None, @@ -173,7 +173,7 @@ : None, : None, : None, - : , + : , : 'funny_chatelet', : None, }), @@ -216,7 +216,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_practical_morse_container_image_update', 'unit_of_measurement': None, @@ -235,7 +235,7 @@ : None, : None, : None, - : , + : , : 'practical_morse', : None, }), @@ -278,7 +278,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_serene_banach_container_image_update', 'unit_of_measurement': None, @@ -297,7 +297,7 @@ : None, : None, : None, - : , + : , : 'serene_banach', : None, }), @@ -340,7 +340,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_stoic_turing_container_image_update', 'unit_of_measurement': None, @@ -359,7 +359,7 @@ : None, : None, : None, - : , + : , : 'stoic_turing', : None, }), diff --git a/tests/components/prana/snapshots/test_init.ambr b/tests/components/prana/snapshots/test_init.ambr index 8c4f89b6b5e9..33a2e69e5955 100644 --- a/tests/components/prana/snapshots/test_init.ambr +++ b/tests/components/prana/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info_registered DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'PRANA RECUPERATOR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ECC9FFE0E574', 'sw_version': '46', 'via_device_id': None, diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 1cf65ea78746..07c70348383c 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -1,5 +1,7 @@ """Tests for Proxmox VE integration.""" +from copy import deepcopy + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -53,6 +55,11 @@ MERGED_PERMISSIONS = { | set(SNAPSHOT_PERMISSIONS) } +PVEVMUSER_PERMISSIONS = deepcopy(MERGED_PERMISSIONS) +# Remove node-level and root-level scopes entirely +PVEVMUSER_PERMISSIONS.pop("/", None) +PVEVMUSER_PERMISSIONS.pop("/nodes", None) + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/proxmoxve/conftest.py b/tests/components/proxmoxve/conftest.py index 1decc74ac46c..8eab09af9095 100644 --- a/tests/components/proxmoxve/conftest.py +++ b/tests/components/proxmoxve/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.proxmoxve.const import ( CONF_TOKEN_SECRET, CONF_VMS, DOMAIN, + ProxmoxPermission, ) from homeassistant.const import ( CONF_HOST, @@ -124,8 +125,12 @@ def mock_proxmox_client(): node_mock.storage.get.return_value = load_json_array_fixture( "nodes/storage.json", DOMAIN ) - node_mock.tasks.get.return_value = load_json_array_fixture( - "nodes/tasks.json", DOMAIN + + node_mock.tasks.get.side_effect = lambda **kwargs: ( + [] + if ProxmoxPermission.SYSAUDIT + not in mock_instance.access.permissions.get.return_value.get("/nodes", []) + else load_json_array_fixture("nodes/tasks.json", DOMAIN) ) qemu_by_vmid = {vm["vmid"]: vm for vm in qemu_list} diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index ef752b8a613e..ff74d97cd4f7 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -652,407 +652,6 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_db-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db', - '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': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'resume', - 'unique_id': '1234_101_resume', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db', - }), - 'context': , - 'entity_id': 'button.vm_db', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_create_snapshot', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Create snapshot', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Create snapshot', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'snapshot_create', - 'unique_id': '1234_101_snapshot_create', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Create snapshot', - }), - 'context': , - 'entity_id': 'button.vm_db_create_snapshot', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_hibernate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Hibernate', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hibernate', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hibernate', - 'unique_id': '1234_101_hibernate', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Hibernate', - }), - 'context': , - 'entity_id': 'button.vm_db_hibernate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_reset', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Reset', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Reset', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'reset', - 'unique_id': '1234_101_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Reset', - }), - 'context': , - 'entity_id': 'button.vm_db_reset', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Restart', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234_101_restart', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'restart', - : 'vm-db Restart', - }), - 'context': , - 'entity_id': 'button.vm_db_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_shut_down', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Shut down', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Shut down', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'shutdown', - 'unique_id': '1234_101_shutdown', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Shut down', - }), - 'context': , - 'entity_id': 'button.vm_db_shut_down', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_start-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_start', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Start', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Start', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'start', - 'unique_id': '1234_101_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Start', - }), - 'context': , - 'entity_id': 'button.vm_db_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_stop', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Stop', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Stop', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'stop', - 'unique_id': '1234_101_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Stop', - }), - 'context': , - 'entity_id': 'button.vm_db_stop', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_button_entities[button.vm_web-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/proxmoxve/test_binary_sensor.py b/tests/components/proxmoxve/test_binary_sensor.py index 4dd60e789f32..d1e2eb5c5983 100644 --- a/tests/components/proxmoxve/test_binary_sensor.py +++ b/tests/components/proxmoxve/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -80,3 +80,48 @@ async def test_refresh_exceptions( state = hass.states.get("binary_sensor.ct_nginx_status") assert state.state == STATE_UNAVAILABLE + + +async def test_binary_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are created when allowed.""" + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" in {e.entity_id for e in entries} + + +async def test_binary_sensors_absent_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" not in {e.entity_id for e in entries} diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index 2b8769949101..abf2bd171bdc 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -11,7 +11,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import AUDIT_PERMISSIONS, setup_integration @@ -362,61 +362,19 @@ async def test_container_buttons_exceptions( ) -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.pve1_shut_down", "no_permission_node_power"), - ("button.pve1_start_all", "no_permission_vm_lxc_power"), - ("button.ct_nginx_start", "no_permission_vm_lxc_power"), - ("button.vm_web_start", "no_permission_vm_lxc_power"), - ("button.vm_web_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_node_buttons_permission_denied_for_auditor_role( +async def test_buttons_only_allowed_buttons( hass: HomeAssistant, mock_proxmox_client: MagicMock, mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test that buttons are raising accordingly for Auditor permissions.""" + """Test that ProxmoxVE button is not generated when not allowed.""" mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS await setup_integration(hass, mock_config_entry) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) - -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.vm_db_start", "no_permission_vm_lxc_power"), - ("button.vm_db_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_vm_buttons_denied_for_specific_vm( - hass: HomeAssistant, - mock_proxmox_client: MagicMock, - mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, -) -> None: - """Test that button only works on actual permissions.""" - await setup_integration(hass, mock_config_entry) - mock_proxmox_client._node_mock.qemu(101) - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + assert all(not entry.entity_id.startswith("button.") for entry in entries) diff --git a/tests/components/proxmoxve/test_sensor.py b/tests/components/proxmoxve/test_sensor.py index f4fc55cb97e5..a2109bbd0372 100644 --- a/tests/components/proxmoxve/test_sensor.py +++ b/tests/components/proxmoxve/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import ( MockConfigEntry, @@ -68,3 +68,26 @@ async def test_storage_missing_used_fraction( state = hass.states.get("sensor.storage_local_storage_usage_percentage") assert state.state == STATE_UNKNOWN + + +async def test_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "sensor.pve1_status" in {e.entity_id for e in entries} + assert "sensor.pve1_cpu" not in {e.entity_id for e in entries} diff --git a/tests/components/ps4/snapshots/test_media_player.ambr b/tests/components/ps4/snapshots/test_media_player.ambr index c4a9da3f2d56..df26937ecc9c 100644 --- a/tests/components/ps4/snapshots/test_media_player.ambr +++ b/tests/components/ps4/snapshots/test_media_player.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Fake PS4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '9.87', 'via_device_id': None, diff --git a/tests/components/ptdevices/fixtures/ptdevices_level.json b/tests/components/ptdevices/fixtures/ptdevices_level.json index c69e7049696d..402992d60a39 100644 --- a/tests/components/ptdevices/fixtures/ptdevices_level.json +++ b/tests/components/ptdevices/fixtures/ptdevices_level.json @@ -27,6 +27,7 @@ "battery_voltage": 5.69, "battery_status": "good", "battery_status_number": 1, + "external_power": 1, "volume_level": 2387.837753, "volume_level_oz": 80742.4, "max_volume": 1269, diff --git a/tests/components/ptdevices/snapshots/test_binary_sensor.ambr b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..354725850c9c --- /dev/null +++ b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.home_battery-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.home_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_battery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Home Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.home_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-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.home_external_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External power', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External power', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_external_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Home External power', + }), + 'context': , + 'entity_id': 'binary_sensor.home_external_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ptdevices/test_binary_sensor.py b/tests/components/ptdevices/test_binary_sensor.py new file mode 100644 index 000000000000..d6ddeeb16e7e --- /dev/null +++ b/tests/components/ptdevices/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test for PTDevices binary sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.ptdevices._PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_ptdevices_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_battery_status_sensor_states( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test battery status binary sensor state recognition.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status is "normal" + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_OFF + + # Set the new battery status to low + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"][ + "battery_status" + ] = "low" + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery status is on (low) + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_binary_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new binary sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status exists + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state != STATE_UNKNOWN + + # Remove the battery_status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop( + "battery_status" + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery_status is no longer present + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/ptdevices/test_sensor.py b/tests/components/ptdevices/test_sensor.py index 494fc632e555..97fc9ab63439 100644 --- a/tests/components/ptdevices/test_sensor.py +++ b/tests/components/ptdevices/test_sensor.py @@ -2,16 +2,18 @@ from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -29,3 +31,31 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the status exists + assert (state := hass.states.get("sensor.home_status")) + assert state.state != STATE_UNKNOWN + + # Remove the status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop("status") + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the status is no longer present + assert (state := hass.states.get("sensor.home_status")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/rabbitair/snapshots/test_init.ambr b/tests/components/rabbitair/snapshots/test_init.ambr index dfa9712d58c1..5f11c7f10471 100644 --- a/tests/components/rabbitair/snapshots/test_init.ambr +++ b/tests/components/rabbitair/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Rabbit Air', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.3.17', 'via_device_id': None, diff --git a/tests/components/rainbird/snapshots/test_init.ambr b/tests/components/rainbird/snapshots/test_init.ambr index 594652e0c857..0c8ec24770de 100644 --- a/tests/components/rainbird/snapshots/test_init.ambr +++ b/tests/components/rainbird/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Rain Bird Controller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '9.12', 'via_device_id': None, diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 9cc89cfcc9ea..2f6c9868e23f 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -7,8 +7,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -29,7 +29,6 @@ 'model_id': 'Z105-2-EMU2-LEDD_JM', 'name': 'RAVEn Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.0 (7400)', 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr index 7b898e593c3d..1e1f48792dd1 100644 --- a/tests/components/renault/snapshots/test_init.ambr +++ b/tests/components/renault/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': 'XJB1SU', 'name': 'REG-CAPTUR-FUEL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -36,8 +35,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -58,7 +57,6 @@ 'model_id': 'XJB1SU', 'name': 'REG-CAPTUR_PHEV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -69,8 +67,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -91,7 +89,6 @@ 'model_id': 'XCB1VE', 'name': 'REG-MEG-0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -102,8 +99,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -124,7 +121,6 @@ 'model_id': 'X071VE', 'name': 'REG-TWINGO-III', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -135,8 +131,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +153,6 @@ 'model_id': 'X101VE', 'name': 'REG-ZOE-40', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -168,8 +163,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -190,7 +185,6 @@ 'model_id': 'X102VE', 'name': 'REG-ZOE-50', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/renson/snapshots/test_init.ambr b/tests/components/renson/snapshots/test_init.ambr index 291d90b9ef9e..18468daea908 100644 --- a/tests/components/renson/snapshots/test_init.ambr +++ b/tests/components/renson/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Ventilation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'Firmware version 4.9.1', 'via_device_id': None, diff --git a/tests/components/ring/snapshots/test_init.ambr b/tests/components/ring/snapshots/test_init.ambr index 8bdcd59d7c0e..50e4aaa793ff 100644 --- a/tests/components/ring/snapshots/test_init.ambr +++ b/tests/components/ring/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Front Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 43e29665ac29..f9346b712b73 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -424,7 +424,6 @@ def make_device_features() -> Mock: device_features = MagicMock(spec=DeviceFeaturesTrait) device_features.is_supported_drying = True device_features.is_support_water_mode = True - device_features.is_clean_fluid_delivery_supported = True device_features.is_support_clean_estimate = True device_features.is_clean_route_setting_supported = True device_features.is_field_supported.return_value = True diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 67ef48ab82d4..bfb169b7c363 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -1,5 +1,6 @@ """Tests for Roborock vacuums.""" +from datetime import timedelta from typing import Any from unittest.mock import Mock, call @@ -45,11 +46,12 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .conftest import FakeDevice, set_trait_attributes from .mock_data import STATUS -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import WebSocketGenerator ENTITY_ID = "vacuum.roborock_s7_maxv" @@ -582,8 +584,7 @@ async def test_segments_changed_issue( }, ) - coordinator = setup_entry.runtime_data.v1[0] - await coordinator.async_refresh() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() issue_id = f"segments_changed_{entity_entry.id}" @@ -593,6 +594,37 @@ async def test_segments_changed_issue( assert issue.translation_key == "segments_changed" +async def test_segments_changed_issue_no_map_info( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + fake_vacuum: FakeDevice, +) -> None: + """Test no repair issue is created when map info is not loaded/empty.""" + entity_entry = entity_registry.async_get(ENTITY_ID) + assert entity_entry is not None + entity_registry.async_update_entity_options( + ENTITY_ID, + VACUUM_DOMAIN, + { + "last_seen_segments": [ + {"id": "1_16", "name": "Example room 1", "group": "Downstairs"}, + {"id": "1_99", "name": "Old room", "group": "Downstairs"}, + ], + }, + ) + + # Map info not loaded + fake_vacuum.v1_properties.home.home_map_info = None + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + issue_id = f"segments_changed_{entity_entry.id}" + issue = ir.async_get(hass).async_get_issue(VACUUM_DOMAIN, issue_id) + assert issue is None + + @pytest.fixture(name="q7_vacuum_api", autouse=False) def fake_q7_vacuum_api_fixture( fake_q7_vacuum: FakeDevice, diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 25925ac38654..ca49cd42e3f6 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_service DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '8381BE 13', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index b02f80f1dfd4..8470ec5fe35f 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.20.75', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'MCA-C5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/rympro/test_init.py b/tests/components/rympro/test_init.py new file mode 100644 index 000000000000..d966f9cf7e30 --- /dev/null +++ b/tests/components/rympro/test_init.py @@ -0,0 +1,69 @@ +"""Test the Read Your Meter Pro integration setup.""" + +from unittest.mock import patch + +from pyrympro import CannotConnectError, OperationError, UnauthorizedError +import pytest + +from homeassistant.components.rympro.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "test-token", + CONF_UNIQUE_ID: "test-account-number", +} + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_DATA, + unique_id=TEST_DATA[CONF_UNIQUE_ID], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.mark.parametrize("exception", [CannotConnectError, OperationError]) +async def test_account_info_error_retries_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + exception: type[Exception], +) -> None: + """Test that a transient account_info error schedules a setup retry.""" + with patch( + "homeassistant.components.rympro.RymPro.account_info", + side_effect=exception, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_relogin_cannot_connect_error_retries_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a connection error while re-authenticating retries setup.""" + with ( + patch( + "homeassistant.components.rympro.RymPro.account_info", + side_effect=UnauthorizedError, + ), + patch( + "homeassistant.components.rympro.RymPro.login", + side_effect=CannotConnectError, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 4be166ecf25b..96f97f1af1ba 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -73,8 +71,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -99,7 +97,6 @@ 'model_id': 'UE43LS003', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr index 86f1ff155827..f6649aefc54d 100644 --- a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr @@ -56,8 +56,8 @@ # name: test_alarm_control_panel[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -78,7 +78,6 @@ 'model_id': None, 'name': 'Home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_binary_sensor.ambr b/tests/components/satel_integra/snapshots/test_binary_sensor.ambr index 5944744b8621..0a8c85b1389a 100644 --- a/tests/components/satel_integra/snapshots/test_binary_sensor.ambr +++ b/tests/components/satel_integra/snapshots/test_binary_sensor.ambr @@ -104,8 +104,8 @@ # name: test_binary_sensors[device-output] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -126,7 +126,6 @@ 'model_id': None, 'name': 'Output', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -135,8 +134,8 @@ # name: test_binary_sensors[device-zone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Zone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_init.ambr b/tests/components/satel_integra/snapshots/test_init.ambr index 9853a728ed61..a88c6922cc6b 100644 --- a/tests/components/satel_integra/snapshots/test_init.ambr +++ b/tests/components/satel_integra/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_parent_device_exists[parent-device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '192.168.0.2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/satel_integra/snapshots/test_sensor.ambr b/tests/components/satel_integra/snapshots/test_sensor.ambr index c8d63dc6b9b4..10c6478b3c6a 100644 --- a/tests/components/satel_integra/snapshots/test_sensor.ambr +++ b/tests/components/satel_integra/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors[device-zone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Zone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_switch.ambr b/tests/components/satel_integra/snapshots/test_switch.ambr index ec4a15864407..5ef82fb54bdc 100644 --- a/tests/components/satel_integra/snapshots/test_switch.ambr +++ b/tests/components/satel_integra/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_switches[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Switchable Output', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/saunum/snapshots/test_init.ambr b/tests/components/saunum/snapshots/test_init.ambr index 473bfe6ce139..1dfc03ed1eb1 100644 --- a/tests/components/saunum/snapshots/test_init.ambr +++ b/tests/components/saunum/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Saunum Leil', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index 1b6cc3f1cdb5..964bcfd5f2e9 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Vault Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0', 'via_device_id': None, diff --git a/tests/components/scrape/snapshots/test_init.ambr b/tests/components/scrape/snapshots/test_init.ambr index 45a049d7835b..a7c010f3aa1c 100644 --- a/tests/components/scrape/snapshots/test_init.ambr +++ b/tests/components/scrape/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_migrate_from_version_1_to_2[device_registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Current version', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index e01ca3ee4bc2..e5544ac276a7 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': 'bedroom', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0987654329', 'sw_version': 'PUR00111', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': 'hallway', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -62,15 +61,14 @@ 'model_id': None, 'name': 'Hallway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'SKY30046', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ }), @@ -91,15 +89,14 @@ 'model_id': None, 'name': 'Hallway Motion Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'V17', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -124,7 +121,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0987654321', 'sw_version': 'PUR00111', 'via_device_id': None, diff --git a/tests/components/sfr_box/snapshots/test_init.ambr b/tests/components/sfr_box/snapshots/test_init.ambr index fc136e73dd1d..b4b7bcb4fffd 100644 --- a/tests/components/sfr_box/snapshots/test_init.ambr +++ b/tests/components/sfr_box/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 9e9461db23ba..aa52014de000 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -7,7 +7,12 @@ from ipaddress import ip_address from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch -from aioshelly.const import DEFAULT_HTTP_PORT, MODEL_1, MODEL_PLUS_2PM +from aioshelly.const import ( + DEFAULT_HTTP_PORT, + DEFAULT_HTTPS_PORT, + MODEL_1, + MODEL_PLUS_2PM, +) from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, @@ -38,6 +43,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -463,6 +469,178 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_https_verify_ssl_disabled_by_default( + hass: HomeAssistant, + mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, +) -> None: + """Test manual setup on port 443 defaults verify_ssl to False.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "type": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "port": DEFAULT_HTTPS_PORT, + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTPS_PORT}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, + CONF_VERIFY_SSL: False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_https_verify_ssl_enabled( + hass: HomeAssistant, + mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, +) -> None: + """Test manual setup on port 443 with verify_ssl enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "type": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "port": DEFAULT_HTTPS_PORT, + "enhanced_security": True, + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("gen", "model"), + [ + (2, MODEL_PLUS_2PM), + (3, MODEL_PLUS_2PM), + ], +) +async def test_form_enhanced_security( + hass: HomeAssistant, + gen: int, + model: str, + mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, +) -> None: + """Test manual setup on port 80 with enhanced_security upgrades to 443.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "model": model, + "auth": False, + "gen": gen, + "enhanced_security": True, + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, + CONF_VERIFY_SSL: False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_enhanced_security_older_firmware( + hass: HomeAssistant, + mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, +) -> None: + """Test manual setup with older firmware that lacks enhanced_security key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_flow_overrides_existing_discovery( hass: HomeAssistant, mock_rpc_device: Mock, @@ -2160,6 +2338,72 @@ async def test_zeroconf( assert result["title"] == "Test name" assert result["data"] == { CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("gen", "model", "get_info"), + [ + ( + 2, + MODEL_PLUS_2PM, + { + "mac": "test-mac", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "enhanced_security": True, + }, + ), + ], +) +async def test_zeroconf_enhanced_security( + hass: HomeAssistant, + gen: int, + model: str, + get_info: dict[str, Any], + mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, +) -> None: + """Test zeroconf discovery with enhanced_security upgrades port to 443.""" + with patch( + "homeassistant.components.shelly.config_flow.get_info", return_value=get_info + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"]["name"] == "shelly1pm-12345" + assert context["confirm_only"] is True + assert context["configuration_url"] == "https://1.1.1.1" + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_VERIFY_SSL: False, CONF_MODEL: model, CONF_SLEEP_PERIOD: 0, CONF_GEN: gen, @@ -2213,6 +2457,7 @@ async def test_zeroconf_sleeping_device( assert result["title"] == "Test name" assert result["data"] == { CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, CONF_MODEL: MODEL_1, CONF_SLEEP_PERIOD: 600, CONF_GEN: 1, @@ -2547,6 +2792,49 @@ async def test_reauth_get_info_error(hass: HomeAssistant) -> None: assert result["reason"] == "reauth_unsuccessful" +async def test_reauth_enhanced_security( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test reauth flow with enhanced_security upgrades port to 443.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: 2, CONF_PORT: DEFAULT_HTTP_PORT}, + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "model": MODEL_PLUS_2PM, + "auth": True, + "gen": 2, + "enhanced_security": True, + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "test password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == { + CONF_HOST: "0.0.0.0", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_GEN: 2, + CONF_USERNAME: "admin", + CONF_PASSWORD: "test password", + CONF_VERIFY_SSL: False, + } + + async def test_options_flow_disabled_gen_1( hass: HomeAssistant, mock_block_device: Mock, hass_ws_client: WebSocketGenerator ) -> None: @@ -3177,6 +3465,48 @@ async def test_reconfigure_with_exception( assert entry.data == {CONF_HOST: "10.10.10.10", CONF_PORT: 99, CONF_GEN: 2} +async def test_reconfigure_enhanced_security( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test reconfigure flow with enhanced_security upgrades port to 443.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: 2}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "enhanced_security": True, + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: DEFAULT_HTTP_PORT}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_HOST: "10.10.10.10", + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_GEN: 2, + CONF_VERIFY_SSL: False, + } + + async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: """Test zeroconf discovery rejects ipv6.""" result = await hass.config_entries.flow.async_init( @@ -3237,6 +3567,7 @@ async def test_zeroconf_wrong_device_name( assert result["title"] == "Test name" assert result["data"] == { CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, CONF_MODEL: MODEL_PLUS_2PM, CONF_SLEEP_PERIOD: 0, CONF_GEN: 2, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index e5c49113173c..09dbd1d210ca 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, call, patch from aioshelly.block_device import COAP from aioshelly.common import ConnectionOptions -from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM +from aioshelly.const import DEFAULT_HTTPS_PORT, MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -34,6 +34,7 @@ from homeassistant.const import ( CONF_HOST, CONF_MODEL, CONF_PORT, + CONF_VERIFY_SSL, STATE_ON, STATE_UNAVAILABLE, ) @@ -524,7 +525,10 @@ async def test_entry_missing_port(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert rpc_device_mock.call_args[0][2] == ConnectionOptions( - ip_address="192.168.1.37", device_mac="123456789ABC", port=80 + ip_address="192.168.1.37", + device_mac="123456789ABC", + port=80, + verify_ssl=False, ) @@ -548,7 +552,38 @@ async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert rpc_device_mock.call_args[0][2] == ConnectionOptions( - ip_address="192.168.1.37", device_mac="123456789ABC", port=8001 + ip_address="192.168.1.37", + device_mac="123456789ABC", + port=8001, + verify_ssl=False, + ) + + +async def test_rpc_entry_https_verify_ssl_disabled(hass: HomeAssistant) -> None: + """Test Gen2 HTTPS setup passes verify_ssl=False to ConnectionOptions.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_GEN: 2, + CONF_PORT: DEFAULT_HTTPS_PORT, + CONF_VERIFY_SSL: False, + } + entry = await init_integration(hass, 2, data=data, skip_setup=True) + with ( + patch("homeassistant.components.shelly.RpcDevice.initialize"), + patch( + "homeassistant.components.shelly.RpcDevice.create", return_value=Mock() + ) as rpc_device_mock, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert rpc_device_mock.call_args[0][2] == ConnectionOptions( + ip_address="192.168.1.37", + device_mac="123456789ABC", + port=DEFAULT_HTTPS_PORT, + verify_ssl=False, ) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 2324b01ab02a..cda4479f3bf6 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -200,31 +200,6 @@ async def test_service_set_kvs_value( mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") -async def test_service_get_kvs_value_config_entry_not_found( - hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry -) -> None: - """Test device with no config entries.""" - entry = await init_integration(hass, 2) - - device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - - # Remove all config entries from device - device_registry.devices[device.id].config_entries.clear() - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, - blocking=True, - return_response=True, - ) - - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device.id} - - async def test_service_get_kvs_value_device_not_initialized( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index 8b9713cb3711..20d4229fa9f8 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.2', 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'slide bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890ab', 'sw_version': '2', 'via_device_id': None, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 99ae823dd973..e700daf95c04 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -40,6 +40,7 @@ MOCK_USER_RECONFIGURE = { CONF_SSL: True, CONF_VERIFY_SSL: False, CONF_GROUP: "user", + CONF_PASSWORD: "new_password", } diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 4c26fcb93175..0e4f8e5c6895 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -8,7 +8,13 @@ import pytest from homeassistant.components.sma.const import CONF_GROUP, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac @@ -338,6 +344,7 @@ async def test_full_flow_reconfigure( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 @@ -385,6 +392,7 @@ async def test_full_flow_reconfigure_exceptions( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index b827e2183c0f..154aebf1f139 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -5,8 +5,8 @@ # name: test_devices[abl_light_b_001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -27,7 +27,6 @@ 'model_id': None, 'name': 'Kitchen Light 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -36,8 +35,8 @@ # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -58,7 +57,6 @@ 'model_id': None, 'name': 'Aeotec Energy Monitor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -67,8 +65,8 @@ # name: test_devices[aeotec_ms6] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -89,7 +87,6 @@ 'model_id': None, 'name': "Parent's Bedroom Sensor", 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -98,8 +95,8 @@ # name: test_devices[aeotec_smart_home_hub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Smart Home Hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '000.059.00008', 'via_device_id': None, @@ -145,8 +141,8 @@ # name: test_devices[aq_sensor_3_ikea] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -171,7 +167,6 @@ 'model_id': None, 'name': 'aq-sensor-3-ikea', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -180,8 +175,8 @@ # name: test_devices[aqara_g350] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -202,7 +197,6 @@ 'model_id': None, 'name': 'G350', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a4367b4d2bbfde94', 'sw_version': '4.5.20', 'via_device_id': None, @@ -211,8 +205,8 @@ # name: test_devices[aux_ac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -233,7 +227,6 @@ 'model_id': None, 'name': 'AUX A/C on-off', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -242,8 +235,8 @@ # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -264,7 +257,6 @@ 'model_id': None, 'name': 'Aeon Energy Monitor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -273,8 +265,8 @@ # name: test_devices[bosch_radiator_thermostat_ii] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -295,7 +287,6 @@ 'model_id': None, 'name': 'Radiator Thermostat II [+M] Wohnzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'D44867FFFEB37584', 'sw_version': '2.00.09', 'via_device_id': None, @@ -304,8 +295,8 @@ # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -326,7 +317,6 @@ 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -335,8 +325,8 @@ # name: test_devices[c2c_shade] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -357,7 +347,6 @@ 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -366,8 +355,8 @@ # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -392,7 +381,6 @@ 'model_id': None, 'name': 'Dimmer Debian', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -401,8 +389,8 @@ # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -427,7 +415,6 @@ 'model_id': None, 'name': '.Front Door Open/Closed Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +423,8 @@ # name: test_devices[copper_water_meter_v03] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -458,7 +445,6 @@ 'model_id': None, 'name': 'Indoor Water Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +453,8 @@ # name: test_devices[da_ac_air_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -489,7 +475,6 @@ 'model_id': None, 'name': 'Air purifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARTIK051_TVTL_18K_12200115', 'via_device_id': None, @@ -498,8 +483,8 @@ # name: test_devices[da_ac_air_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -520,7 +505,6 @@ 'model_id': None, 'name': 'Air filter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AVT-WW-TP1-22-TOUCHOTN_12240702', 'via_device_id': None, @@ -529,8 +513,8 @@ # name: test_devices[da_ac_airsensor_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -551,7 +535,6 @@ 'model_id': None, 'name': '에어모니터 플러스', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', 'via_device_id': None, @@ -560,8 +543,8 @@ # name: test_devices[da_ac_cac_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -582,7 +565,6 @@ 'model_id': None, 'name': 'Ar Varanda', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ASA-WW-TP1-24-PACCOM_14240625', 'via_device_id': None, @@ -591,8 +573,8 @@ # name: test_devices[da_ac_ehs_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -613,7 +595,6 @@ 'model_id': None, 'name': 'Heat pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', 'via_device_id': None, @@ -622,8 +603,8 @@ # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -644,7 +625,6 @@ 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +633,8 @@ # name: test_devices[da_ac_rac_000003] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -675,7 +655,6 @@ 'model_id': None, 'name': 'Clim Salon', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARTIK051_PRAC_20K_11230313', 'via_device_id': None, @@ -684,8 +663,8 @@ # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -706,7 +685,6 @@ 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, @@ -715,8 +693,8 @@ # name: test_devices[da_ac_rac_100001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -737,7 +715,6 @@ 'model_id': None, 'name': 'Corridor A/C', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +723,8 @@ # name: test_devices[da_ks_cooktop_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -768,7 +745,6 @@ 'model_id': None, 'name': 'Table de cuisson', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP2X-20-COOKTOP_40230515', 'via_device_id': None, @@ -777,8 +753,8 @@ # name: test_devices[da_ks_cooktop_31001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -799,7 +775,6 @@ 'model_id': 'NZ64B5046GK', 'name': 'Induction Hob', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'B8C878DX900290H', 'sw_version': None, 'via_device_id': None, @@ -808,8 +783,8 @@ # name: test_devices[da_ks_hood_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -830,7 +805,6 @@ 'model_id': None, 'name': 'Range hood', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AHD-WW-TP1-22-COMMON_40230419', 'via_device_id': None, @@ -839,8 +813,8 @@ # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -861,7 +835,6 @@ 'model_id': None, 'name': 'Microwave', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, @@ -870,8 +843,8 @@ # name: test_devices[da_ks_oven_01061] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -892,7 +865,6 @@ 'model_id': None, 'name': 'Oven', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', 'via_device_id': None, @@ -901,8 +873,8 @@ # name: test_devices[da_ks_oven_0107x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -923,7 +895,6 @@ 'model_id': None, 'name': 'Kitchen oven', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1-22-OVEN-1_40250221', 'via_device_id': None, @@ -932,8 +903,8 @@ # name: test_devices[da_ks_range_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -954,7 +925,6 @@ 'model_id': None, 'name': 'Vulcan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', 'via_device_id': None, @@ -963,8 +933,8 @@ # name: test_devices[da_ks_walloven_0107x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -985,7 +955,6 @@ 'model_id': None, 'name': 'Four', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20230413.181729', 'via_device_id': None, @@ -994,8 +963,8 @@ # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1016,7 +985,6 @@ 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, @@ -1025,8 +993,8 @@ # name: test_devices[da_ref_normal_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1047,7 +1015,6 @@ 'model_id': None, 'name': 'Refrigerator 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20240616.213423', 'via_device_id': None, @@ -1056,8 +1023,8 @@ # name: test_devices[da_ref_normal_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1078,7 +1045,6 @@ 'model_id': None, 'name': 'Frigo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', 'via_device_id': None, @@ -1087,8 +1053,8 @@ # name: test_devices[da_ref_normal_01011_onedoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1109,7 +1075,6 @@ 'model_id': 'RR39C7EC5B1/EF', 'name': 'Lodówka', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP1-24-T4-COM_20250706', 'via_device_id': None, @@ -1118,8 +1083,8 @@ # name: test_devices[da_ref_normal_100001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1140,7 +1105,6 @@ 'model_id': None, 'name': 'Kjøleskap', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1113,8 @@ # name: test_devices[da_rvc_map_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1171,7 +1135,6 @@ 'model_id': None, 'name': 'Robot Vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20260120.215157', 'via_device_id': None, @@ -1180,8 +1143,8 @@ # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1202,7 +1165,6 @@ 'model_id': None, 'name': 'Robot vacuum 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0', 'via_device_id': None, @@ -1211,8 +1173,8 @@ # name: test_devices[da_sac_ehs_000001_sub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1233,7 +1195,6 @@ 'model_id': None, 'name': 'Eco Heating System', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1242,8 +1203,8 @@ # name: test_devices[da_sac_ehs_000001_sub_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1264,7 +1225,6 @@ 'model_id': None, 'name': 'Heat Pump Main', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1273,8 +1233,8 @@ # name: test_devices[da_sac_ehs_000002_sub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1295,7 +1255,6 @@ 'model_id': None, 'name': 'Wärmepumpe', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1304,8 +1263,8 @@ # name: test_devices[da_vc_stick_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1326,7 +1285,6 @@ 'model_id': 'VS28C9784QK/WA', 'name': 'Stick vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-VSWW-TP1-23-VS9700_51250514', 'via_device_id': None, @@ -1335,8 +1293,8 @@ # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1357,7 +1315,6 @@ 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, @@ -1366,8 +1323,8 @@ # name: test_devices[da_wm_dw_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1388,7 +1345,6 @@ 'model_id': 'DW60BG850B00ET', 'name': 'Dishwasher 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DW_TP1_21_COMMON_30250513', 'via_device_id': None, @@ -1397,8 +1353,8 @@ # name: test_devices[da_wm_mf_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1419,7 +1375,6 @@ 'model_id': None, 'name': 'Filtro in microfibra', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AMF-WW-TP1-22-COMMON_30230323', 'via_device_id': None, @@ -1428,8 +1383,8 @@ # name: test_devices[da_wm_sc_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1450,7 +1405,6 @@ 'model_id': None, 'name': 'AirDresser', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', 'via_device_id': None, @@ -1459,8 +1413,8 @@ # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1481,7 +1435,6 @@ 'model_id': None, 'name': 'Dryer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1490,8 +1443,8 @@ # name: test_devices[da_wm_wd_000001_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1512,7 +1465,6 @@ 'model_id': None, 'name': 'Seca-Roupa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1521,8 +1473,8 @@ # name: test_devices[da_wm_wd_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1543,7 +1495,6 @@ 'model_id': 'DV90DB8845GHU2', 'name': 'Trockner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30250508', 'via_device_id': None, @@ -1552,8 +1503,8 @@ # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1574,7 +1525,6 @@ 'model_id': None, 'name': 'Washer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, @@ -1583,8 +1533,8 @@ # name: test_devices[da_wm_wm_000001_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1605,7 +1555,6 @@ 'model_id': None, 'name': 'Washing Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1614,8 +1563,8 @@ # name: test_devices[da_wm_wm_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1636,7 +1585,6 @@ 'model_id': None, 'name': 'Machine à Laver', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', 'via_device_id': None, @@ -1645,8 +1593,8 @@ # name: test_devices[da_wm_wm_100001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1667,7 +1615,6 @@ 'model_id': None, 'name': 'Washer 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1676,8 +1623,8 @@ # name: test_devices[da_wm_wm_100002] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1698,7 +1645,6 @@ 'model_id': None, 'name': 'Washer 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1707,8 +1653,8 @@ # name: test_devices[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1729,7 +1675,6 @@ 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250206213001', 'via_device_id': None, @@ -1738,8 +1683,8 @@ # name: test_devices[ecobee_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1760,7 +1705,6 @@ 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250206151734', 'via_device_id': None, @@ -1769,8 +1713,8 @@ # name: test_devices[ecobee_thermostat_offline] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1791,7 +1735,6 @@ 'model_id': None, 'name': 'Downstairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250308073247', 'via_device_id': None, @@ -1800,8 +1743,8 @@ # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1822,7 +1765,6 @@ 'model_id': None, 'name': 'Fake fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1831,8 +1773,8 @@ # name: test_devices[fibaro_dimmer_2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1853,7 +1795,6 @@ 'model_id': None, 'name': 'Dimmer entré 1 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1862,8 +1803,8 @@ # name: test_devices[gas_detector] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -1888,7 +1829,6 @@ 'model_id': None, 'name': 'Gas Detector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1897,8 +1837,8 @@ # name: test_devices[gas_meter] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1919,7 +1859,6 @@ 'model_id': None, 'name': 'Gas Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1928,8 +1867,8 @@ # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1950,7 +1889,6 @@ 'model_id': None, 'name': 'Basement Exit Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1959,8 +1897,8 @@ # name: test_devices[generic_ef00_v1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -1985,7 +1923,6 @@ 'model_id': None, 'name': 'Thermostat Küche', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1994,8 +1931,8 @@ # name: test_devices[generic_fan_3_speed] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2016,7 +1953,6 @@ 'model_id': None, 'name': 'Bedroom Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2025,8 +1961,8 @@ # name: test_devices[heatit_zpushwall] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2047,7 +1983,6 @@ 'model_id': None, 'name': 'Livingroom smart switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2056,8 +1991,8 @@ # name: test_devices[heatit_ztrm3_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2078,7 +2013,6 @@ 'model_id': None, 'name': 'Hall thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2087,8 +2021,8 @@ # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2109,7 +2043,6 @@ 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.122.2', 'via_device_id': None, @@ -2118,8 +2051,8 @@ # name: test_devices[hue_rgbw_color_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2140,7 +2073,6 @@ 'model_id': None, 'name': 'Standing light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.122.2', 'via_device_id': None, @@ -2149,8 +2081,8 @@ # name: test_devices[hw_q80r_soundbar] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2171,7 +2103,6 @@ 'model_id': None, 'name': 'Soundbar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'HW-Q80RWWB-1012.6', 'via_device_id': None, @@ -2180,8 +2111,8 @@ # name: test_devices[ikea_kadrilj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2206,7 +2137,6 @@ 'model_id': None, 'name': 'Kitchen IKEA KADRILJ Window blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2215,8 +2145,8 @@ # name: test_devices[ikea_leak_battery] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2237,7 +2167,6 @@ 'model_id': None, 'name': 'Waschkeller Feuchtigkeitssensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.11', 'via_device_id': , @@ -2246,8 +2175,8 @@ # name: test_devices[ikea_motion_illuminance_battery] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2268,7 +2197,6 @@ 'model_id': None, 'name': 'Gaderobe Bewegungsmelder', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.7', 'via_device_id': , @@ -2277,8 +2205,8 @@ # name: test_devices[ikea_plug_powermeter] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2303,7 +2231,6 @@ 'model_id': None, 'name': 'IKEA Plug Powermeter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -2312,8 +2239,8 @@ # name: test_devices[im_smarttag2_ble_uwb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2334,7 +2261,6 @@ 'model_id': None, 'name': 'SmartTag+ black', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2343,8 +2269,8 @@ # name: test_devices[im_speaker_ai_0001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2365,7 +2291,6 @@ 'model_id': None, 'name': 'Galaxy Home Mini', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'V310XXU1AWK1', 'via_device_id': None, @@ -2374,8 +2299,8 @@ # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2396,7 +2321,6 @@ 'model_id': None, 'name': 'iPhone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2405,8 +2329,8 @@ # name: test_devices[lumi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2431,7 +2355,6 @@ 'model_id': None, 'name': 'Outdoor Temp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2440,8 +2363,8 @@ # name: test_devices[meross_plug] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2462,7 +2385,6 @@ 'model_id': None, 'name': 'Waschkeller Trockner Plug', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '510802250905784', 'sw_version': '9.3.26', 'via_device_id': , @@ -2471,8 +2393,8 @@ # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2497,7 +2419,6 @@ 'model_id': None, 'name': 'Deck Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2506,8 +2427,8 @@ # name: test_devices[sensi_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2528,7 +2449,6 @@ 'model_id': None, 'name': 'Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '6004971003', 'via_device_id': None, @@ -2537,8 +2457,8 @@ # name: test_devices[sensibo_airconditioner_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2559,7 +2479,6 @@ 'model_id': None, 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SKY40147', 'via_device_id': None, @@ -2568,8 +2487,8 @@ # name: test_devices[siemens_washer] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2590,7 +2509,6 @@ 'model_id': None, 'name': 'Wasmachine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2599,8 +2517,8 @@ # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2625,7 +2543,6 @@ 'model_id': None, 'name': 'Arlo Beta Basestation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2634,8 +2551,8 @@ # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2656,7 +2573,6 @@ 'model_id': None, 'name': 'Elliots Rum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2665,8 +2581,8 @@ # name: test_devices[tesla_powerwall] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2687,7 +2603,6 @@ 'model_id': None, 'name': 'Powerwall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2696,8 +2611,8 @@ # name: test_devices[tplink_p110] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2718,7 +2633,6 @@ 'model_id': None, 'name': 'Spülmaschine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.3.1 Build 240621 Rel.162048', 'via_device_id': None, @@ -2727,8 +2641,8 @@ # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2749,7 +2663,6 @@ 'model_id': None, 'name': 'Soundbar Living', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, @@ -2758,8 +2671,8 @@ # name: test_devices[vd_network_audio_003s] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2780,7 +2693,6 @@ 'model_id': None, 'name': 'Soundbar 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SAT-MT8532D24WWC-1016.0', 'via_device_id': None, @@ -2789,8 +2701,8 @@ # name: test_devices[vd_sensor_light_2023] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2811,7 +2723,6 @@ 'model_id': None, 'name': 'Light Sensor - 55" The Frame', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'latest', 'via_device_id': None, @@ -2820,8 +2731,8 @@ # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2842,7 +2753,6 @@ 'model_id': None, 'name': '[TV] Samsung 8 Series (49)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, @@ -2851,8 +2761,8 @@ # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2873,7 +2783,6 @@ 'model_id': None, 'name': 'virtual thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2882,8 +2791,8 @@ # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2904,7 +2813,6 @@ 'model_id': None, 'name': 'volvo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2913,8 +2821,8 @@ # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2935,7 +2843,6 @@ 'model_id': None, 'name': 'virtual water sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2944,8 +2851,8 @@ # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2970,7 +2877,6 @@ 'model_id': None, 'name': 'Basement Door Lock', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2979,8 +2885,8 @@ # name: test_hub_via_device DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -3009,7 +2915,6 @@ 'model_id': None, 'name': 'Home Hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '000.055.00005', 'via_device_id': None, diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index 109fd649533e..4bab38a90090 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '127', 'via_device_id': None, diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 7f46daef13cd..3a78736a682e 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 4af1b2e703e5..27336b6a7cb6 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -51,11 +51,13 @@ def mock_smtp() -> Generator[MagicMock]: with ( patch( - "homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True + "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True ) as mock_client, + patch("homeassistant.components.smtp.helpers.smtplib.SMTP", new=mock_client), patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), ): client = mock_client.return_value + client.cls = mock_client yield client @@ -70,17 +72,6 @@ def mock_make_msgid() -> Generator[None]: yield -@pytest.fixture(name="smtp_ssl") -def mock_smtp_ssl() -> Generator[MagicMock]: - """Mock SMTP.""" - - with patch( - "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True - ) as mock_client: - client = mock_client.return_value - yield client - - @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock smtp configuration entry.""" @@ -89,7 +80,7 @@ def mock_config_entry() -> MockConfigEntry: title="Home Assistant", data=USER_INPUT, options={ - CONF_TIMEOUT: 5, + CONF_TIMEOUT: 1312, }, entry_id="123456789", subentries_data=[ diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index fd8f82d13e21..2708daee3e90 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.smtp.const import ( CONF_ENCRYPTION, CONF_SENDER_NAME, DOMAIN, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.config_entries import ( @@ -37,10 +38,9 @@ from .conftest import USER_INPUT from tests.common import MockConfigEntry -@pytest.mark.usefixtures("smtp", "smtp_ssl") @pytest.mark.parametrize("encryption", ["tls", "starttls"]) async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str, smtp: MagicMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -54,6 +54,7 @@ async def test_form( { **USER_INPUT, CONF_ENCRYPTION: encryption, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, }, ) await hass.async_block_till_done() @@ -64,6 +65,7 @@ async def test_form( **USER_INPUT, CONF_ENCRYPTION: encryption, } + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done(wait_background_tasks=True) @@ -79,6 +81,8 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Recipient" assert result["unique_id"] == "recipient@example.com" + assert smtp.cls.call_args[0] == ("mail.example.com", 587) + assert smtp.cls.call_args[1]["timeout"] == 60 @pytest.mark.usefixtures("smtp") @@ -98,7 +102,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() @@ -134,7 +141,10 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) assert result["type"] is FlowResultType.FORM @@ -144,13 +154,17 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" assert result["data"] == USER_INPUT + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 @@ -215,9 +229,8 @@ async def test_options_flow( } -@pytest.mark.usefixtures("smtp") async def test_form_reconfigure( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock ) -> None: """Test reconfigure flow.""" @@ -250,6 +263,7 @@ async def test_form_reconfigure( } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.usefixtures("smtp") @@ -358,8 +372,9 @@ async def test_form_reconfigure_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("smtp") -async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_form_reauth( + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock +) -> None: """Test reauth flow.""" config_entry.add_to_hass(hass) @@ -388,6 +403,7 @@ async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) - } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.parametrize( diff --git a/tests/components/snooz/snapshots/test_init.ambr b/tests/components/snooz/snapshots/test_init.ambr index ef893776b22d..2428bdd78c12 100644 --- a/tests/components/snooz/snapshots/test_init.ambr +++ b/tests/components/snooz/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': None, 'name_by_user': None, - 'primary_config_entry': None, 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/sonos/snapshots/test_diagnostics.ambr b/tests/components/sonos/snapshots/test_diagnostics.ambr index 9e3dfcb47e79..a4b1500de962 100644 --- a/tests/components/sonos/snapshots/test_diagnostics.ambr +++ b/tests/components/sonos/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', @@ -112,6 +113,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', diff --git a/tests/components/sonos/test_button.py b/tests/components/sonos/test_button.py new file mode 100644 index 000000000000..60a2617bcd8c --- /dev/null +++ b/tests/components/sonos/test_button.py @@ -0,0 +1,143 @@ +"""Tests for the Sonos button platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from sonos_websocket import CLIP_ID_KEY +from sonos_websocket.exception import SonosWebsocketError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +CANCEL_ANNOUNCEMENT_BUTTON = "button.zone_a_cancel_announcement" + + +async def _announce_clip(hass: HomeAssistant, content_id: str) -> None: + """Play an announcement clip to set the active clip id.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + +async def test_cancel_announcement_no_prior( + hass: HomeAssistant, + async_autosetup_sonos, +) -> None: + """Test cancelling when no announcement has been played.""" + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +async def test_cancel_announcement( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling a currently playing announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock(return_value=[{"success": 1}, {}]) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + sonos_websocket.cancel_clip.assert_called_once_with("clip-123") + + +async def test_cancel_announcement_no_clip_id_from_announce_response( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling fails when the announce response has no clip ID.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [{"success": 1}, None] + await _announce_clip(hass, content_id) + + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("cancel_clip_side_effect", "cancel_clip_return", "error_match"), + [ + pytest.param( + SonosWebsocketError("Connection lost"), + None, + "Failed to reach Sonos speaker for announcement: Connection lost", + id="websocket_error", + ), + pytest.param( + None, + [{"success": 0}, {}], + "Cancelling announcement failed", + id="non_success_response", + ), + ], +) +async def test_cancel_announcement_errors( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, + cancel_clip_side_effect: SonosWebsocketError | None, + cancel_clip_return: list[dict[str, Any]] | None, + error_match: str, +) -> None: + """Test error handling when cancelling an announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock( + side_effect=cancel_clip_side_effect, + return_value=cancel_clip_return, + ) + with pytest.raises(HomeAssistantError, match=error_match): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index f718428acab6..4092d5f1a1ed 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,10 +1,12 @@ """Tests for the Sonos config flow.""" import asyncio +from collections.abc import Callable, Coroutine from http import HTTPStatus from itertools import chain, repeat import logging -from unittest.mock import Mock, PropertyMock, patch +from typing import Any +from unittest.mock import MagicMock, Mock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -21,7 +23,11 @@ from homeassistant.components.sonos.const import ( from homeassistant.components.sonos.exception import SonosUpdateError from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component @@ -167,6 +173,64 @@ async def test_discovery_exception( assert "This is a test" in caplog.text +async def test_discovery_skips_disabled_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + soco: MockSoCo, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test discovery message handling skips disabled Sonos devices.""" + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(sonos.DOMAIN, soco.uid)}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + soco.zoneGroupTopology.subscribe.assert_not_awaited() + assert not er.async_entries_for_device(entity_registry, device.id) + + +async def test_discovery_reenable_device_on_new_discovery( + hass: HomeAssistant, + config_entry: MockConfigEntry, + soco: MockSoCo, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + discover: MagicMock, + fire_zgs_event: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Test re-enabling a disabled device allows subscriptions and entities.""" + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(sonos.DOMAIN, soco.uid)}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + soco.zoneGroupTopology.subscribe.assert_not_awaited() + assert not er.async_entries_for_device(entity_registry, device.id) + + device_registry.async_update_device(device.id, disabled_by=None) + + # Re-run discovery using the fixture's own mocked callback path. + discover.side_effect(*discover.call_args.args, **discover.call_args.kwargs) + await hass.async_block_till_done(wait_background_tasks=True) + + await fire_zgs_event() + await hass.async_block_till_done(wait_background_tasks=True) + + assert soco.zoneGroupTopology.subscribe.await_count > 0 + assert er.async_entries_for_device(entity_registry, device.id) + + async def test_async_poll_manual_hosts_warnings( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -598,6 +662,39 @@ async def test_async_poll_manual_hosts_8( await hass.async_block_till_done(wait_background_tasks=True) +async def test_async_poll_manual_hosts_skips_disabled_visible_zone( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test disabled visible zone is skipped in visible-zones expansion.""" + soco_1 = soco_factory.cache_mock( + _MockSoCoVisibleZones(), "10.10.10.1", "Living Room" + ) + # Host 2 is marked disabled in the device registry. + # Host 1's visible-zones expansion encounters host 2 and exercises the + # _async_add_visible_zones disabled filter branch. + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + + soco_1.set_visible_zones({soco_1, soco_2}) + + config_entry = MockConfigEntry(domain=sonos.DOMAIN) + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(sonos.DOMAIN, soco_2.uid)}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + + await _setup_hass(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "media_player.living_room" in entity_registry.entities + assert "media_player.bedroom" not in entity_registry.entities + await hass.async_block_till_done(wait_background_tasks=True) + + async def _setup_hass_ipv6_address_not_supported(hass: HomeAssistant): await async_setup_component( hass, diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 4dac45065347..18db04c55ca3 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -13,6 +13,7 @@ from soco.data_structures import ( DidlPlaylistContainer, SearchResult, ) +from soco.exceptions import SoCoUPnPException from sonos_websocket.exception import SonosWebsocketError from syrupy.assertion import SnapshotAssertion @@ -326,6 +327,69 @@ async def test_play_media_library_content_error( ) +@pytest.mark.parametrize( + ("error", "translation_key", "translation_placeholders"), + [ + pytest.param( + OSError("Network down"), + "call_failed", + { + "target": "media_player.zone_a", + "error": "Network down", + }, + id="generic-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 701 received", "701", ""), + "upnp_call_failed", + { + "target": "media_player.zone_a", + "error": "UPnP Error 701 received", + "error_code": "701", + }, + id="upnp-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 800 received", "800", ""), + "upnp_call_failed_music_service_unavailable", + { + "target": "media_player.zone_a", + "error": "UPnP Error 800 received", + "error_code": "800", + }, + id="upnp-error-800-music-service-unavailable", + ), + ], +) +async def test_play_media_error_translation( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + error: Exception, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test play_media surfaces translated error details for failures.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + soco_mock.play_uri.side_effect = error + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "track", + ATTR_MEDIA_CONTENT_ID: _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.REPLACE, + }, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert err.value.translation_placeholders == translation_placeholders + + _track_url = "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3" diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr index 03678ef4ff83..c4e33fea3244 100644 --- a/tests/components/squeezebox/snapshots/test_init.ambr +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Test Player', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '', 'via_device_id': , @@ -37,8 +36,8 @@ # name: test_device_registry_server_merged DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -67,7 +66,6 @@ 'model_id': 'LMS', 'name': '1.1.1.1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '', 'via_device_id': , diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 3e1d1322e7dc..25dac8d3b058 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -763,6 +763,11 @@ async def test_bind_failure_skips_adapter( if self.source == ("2001:db8::", 0, 0, 1): raise OSError + # The UPnP server needs a presentation URL, which is derived from the + # instance URL. In production http is set up before ssdp; set an internal + # URL here so get_url() succeeds without relying on http being set up. + hass.config.internal_url = "http://10.10.10.10:8123" + SsdpListener.async_start = _async_start UpnpServer.async_start = _async_start await init_ssdp_component(hass) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index 7dca15875689..3901f464d219 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -158,18 +158,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, statistics_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the statistics config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() @@ -181,15 +173,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.statistics.async_unload_entry", wraps=statistics.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -197,6 +186,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_statistics") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +354,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes statistics config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" statistics_config_entry = MockConfigEntry( data={}, @@ -382,22 +374,13 @@ async def test_migration_1_1( ) statistics_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() assert statistics_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # 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") diff --git a/tests/components/steam_online/fixtures/GetPlayerSummaries.json b/tests/components/steam_online/fixtures/GetPlayerSummaries.json index d3aa4bf87dc0..c81c15a95065 100644 --- a/tests/components/steam_online/fixtures/GetPlayerSummaries.json +++ b/tests/components/steam_online/fixtures/GetPlayerSummaries.json @@ -19,7 +19,8 @@ "realname": "John Dough", "personastateflags": 0, "gameextrainfo": "The Witcher: Enhanced Edition", - "gameid": "20900" + "gameid": "20900", + "lobbysteamid": "109775243377594361" }, { "steamid": "12345678912345678", diff --git a/tests/components/steam_online/snapshots/test_init.ambr b/tests/components/steam_online/snapshots/test_init.ambr index 9cec5ffc35b0..17711814c7c8 100644 --- a/tests/components/steam_online/snapshots/test_init.ambr +++ b/tests/components/steam_online/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://steamcommunity.com/profiles/123456789/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'testaccount1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index 7cedf7dc476d..53e3394f83a7 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -24,7 +26,7 @@ 'object_id_base': 'Water price', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Water price', 'platform': 'suez_water', @@ -33,16 +35,16 @@ 'supported_features': 0, 'translation_key': 'water_price', 'unique_id': '123456_water_price', - 'unit_of_measurement': '€', + 'unit_of_measurement': '€/m³', }) # --- # name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by toutsurmoneau.fr', - : 'monetary', : 'Suez mock device Water price', - : '€', + : , + : '€/m³', }), 'context': , 'entity_id': 'sensor.suez_mock_device_water_price', diff --git a/tests/components/sunricher_dali/snapshots/test_init.ambr b/tests/components/sunricher_dali/snapshots/test_init.ambr index ca94d3b5dffe..87052be3a781 100644 --- a/tests/components/sunricher_dali/snapshots/test_init.ambr +++ b/tests/components/sunricher_dali/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Test Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6A242121110E', 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -58,15 +57,14 @@ 'model_id': None, 'name': 'Dimmer 0000-02', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -87,15 +85,14 @@ 'model_id': None, 'name': 'CCT 0000-03', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -116,15 +113,14 @@ 'model_id': None, 'name': 'HS Color Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -145,7 +141,6 @@ 'model_id': None, 'name': 'RGBW Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 6aed898fadf9..f3cddd346f03 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -208,12 +208,6 @@ async def test_device_registry_config_entry_1( device_id=device_entry.id, original_name="ABC", ) - # Add another config entry to the same device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=other_config_entry.entry_id - ) switch_as_x_config_entry = MockConfigEntry( data={}, @@ -246,15 +240,12 @@ async def test_device_registry_config_entry_1( async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) - # Remove the wrapped switch's config entry from the device, this removes the - # wrapped switch + # Remove the wrapped switch, this removes the switch_as_x config entry with patch( "homeassistant.components.switch_as_x.async_unload_entry", wraps=switch_as_x.async_unload_entry, ) as mock_setup_entry: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=switch_config_entry.entry_id - ) + entity_registry.async_remove(switch_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -1134,9 +1125,6 @@ async def test_migrate( minor_version=1, ) config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=config_entry.entry_id - ) switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", @@ -1179,19 +1167,9 @@ async def test_migrate( assert hass.states.get(f"{target_domain}.abc") is not None assert entity_registry.async_get(f"{target_domain}.abc") is not None - # Entity removed from device to prevent deletion, then added back to device - assert events == [ - { - "action": "update", - "changes": {"device_id": device_entry.id}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - { - "action": "update", - "changes": {"device_id": None}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - ] + # 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 + assert events == [] @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 42dedc115dc7..684bc3a6f3ec 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -53,8 +53,8 @@ # name: test_number_entities[binary_sensor.door_1_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': None, 'name': 'Door 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , @@ -135,8 +134,8 @@ # name: test_number_entities[binary_sensor.door_2_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Door 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index c3e135498986..a1e7656951ed 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_number_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': None, 'name': 'Tailwind iQ3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': None, diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index e6670bc31ed3..d3aa188a6931 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -55,8 +55,8 @@ # name: test_cover_entities[cover.door_1].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -77,7 +77,6 @@ 'model_id': None, 'name': 'Door 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , @@ -139,8 +138,8 @@ # name: test_cover_entities[cover.door_2].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -161,7 +160,6 @@ 'model_id': None, 'name': 'Door 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index 46d08aac7c3b..737ceee9b1e4 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -62,8 +62,8 @@ # name: test_number_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -88,7 +88,6 @@ 'model_id': None, 'name': 'Tailwind iQ3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': None, diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 1c987f7466c3..77a231826a26 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -23,6 +23,20 @@ from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient, WebSocketGenerator +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + 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): + if device.config_entry_id == config_entry_id: + return device + return None + + async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -324,12 +338,21 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + tasmota_device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) - assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert tasmota_device_entry is not None + assert tasmota_device_entry.config_entries == {tasmota_entry.entry_id} + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + assert mock_device_entry is not None async_fire_mqtt_message( hass, @@ -338,9 +361,19 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is not removed - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify the Tasmota device is removed, but the other config entry's device is not + assert ( + _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + is None + ) + device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None assert device_entry.config_entries == {mock_entry.entry_id} @@ -378,21 +411,29 @@ async def test_device_remove_multiple_config_entries_2( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert device_entry.config_entries == {tasmota_entry.entry_id} assert other_device_entry.id != device_entry.id - # Remove other config entry from the device + # Remove the config entry from the other (non-Tasmota) device sharing the connection + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=mock_entry.entry_id + mock_device_entry.id, remove_config_entry_id=mock_entry.entry_id ) await hass.async_block_till_done() - # Verify device entry is not removed + # Verify the Tasmota device entry is not removed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 38874d08f3af..e356fd427b19 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_bridge_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Bridge-AB1C', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0000-0000', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_lock_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'Tedee PRO', 'name': 'Lock-1A2B', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index 456df2b3c34c..69ff7de8b147 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -53,8 +53,8 @@ # name: test_lock_without_pullspring.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': 'Tedee GO', 'name': 'Lock-2C3D', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/telegram_bot/test_init.py b/tests/components/telegram_bot/test_init.py index f5d54e19b241..749bc10ebc19 100644 --- a/tests/components/telegram_bot/test_init.py +++ b/tests/components/telegram_bot/test_init.py @@ -1,16 +1,21 @@ """Init tests for the Telegram Bot integration.""" +import pytest + from homeassistant.components.telegram_bot.const import ( ATTR_PARSER, + CONF_ALLOWED_CHAT_IDS, CONF_API_ENDPOINT, + CONF_CHAT_ID, DEFAULT_API_ENDPOINT, DOMAIN, PARSER_MD, PLATFORM_BROADCAST, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_PLATFORM from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -19,7 +24,7 @@ async def test_migration_error( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from unsupported version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -43,7 +48,7 @@ async def test_migrate_entry_from_1_1( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from 1.1, chaining through to the latest version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -61,9 +66,174 @@ async def test_migrate_entry_from_1_1( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.version == 1 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, } + + +@pytest.mark.parametrize("collapsed_chat_index", [0, 1]) +@pytest.mark.parametrize( + "chats_without_notify_entity", + [ + pytest.param((), id="notify entities intact"), + pytest.param((654321,), id="notify entity deleted"), + ], +) +async def test_migrate_entry_to_per_chat_devices( + hass: HomeAssistant, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + collapsed_chat_index: int, + chats_without_notify_entity: tuple[int, ...], +) -> None: + """Test migrating chats sharing one bot device to per-chat devices.""" + bot_id = 123456 # test_user id from mock_external_calls + chat_ids = (123456, 654321) + config_entry = MockConfigEntry( + unique_id="mock api key", + domain=DOMAIN, + minor_version=2, + data={ + CONF_PLATFORM: PLATFORM_BROADCAST, + CONF_API_KEY: "mock api key", + CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, + }, + options={ATTR_PARSER: PARSER_MD}, + subentries_data=[ + ConfigSubentryData( + unique_id="123456", + data={CONF_CHAT_ID: 123456}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 1", + ), + ConfigSubentryData( + unique_id="654321", + data={CONF_CHAT_ID: 654321}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 2", + ), + ], + ) + config_entry.add_to_hass(hass) + subentry_ids = list(config_entry.subentries) + + # Post-store-migration state: one shared bot device collapsed onto an arbitrary chat + # subentry, holding the event entity and every surviving chat's notify entity. + bot_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_ids[collapsed_chat_index], + identifiers={(DOMAIN, str(bot_id))}, + ) + event_entity = entity_registry.async_get_or_create( + "event", + DOMAIN, + f"{bot_id}_update_event", + config_entry=config_entry, + device_id=bot_device.id, + ) + notify_entities = { + chat_id: entity_registry.async_get_or_create( + "notify", + DOMAIN, + f"{bot_id}_{chat_id}", + config_entry=config_entry, + config_subentry_id=subentry_id, + device_id=bot_device.id, + ) + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True) + if chat_id not in chats_without_notify_entity + } + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.minor_version == 3 + + # Every chat has its own device - owned by that subentry and linked to the bot device - + # even a chat whose notify entity was deleted before the migration ran. A surviving + # notify entity is moved onto its chat's device. + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): + chat_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{bot_id}_{chat_id}")} + ) + assert chat_device is not None + assert chat_device.config_subentry_id == subentry_id + assert chat_device.via_device_id == bot_device.id + if chat_id in notify_entities: + assert ( + entity_registry.async_get(notify_entities[chat_id].entity_id).device_id + == chat_device.id + ) + + # The bot device was handed back to the config entry, keeping the event entity + bot_device = device_registry.async_get(bot_device.id) + assert bot_device is not None + assert bot_device.config_subentry_id is None + assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id + + +async def test_per_chat_devices( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Each chat gets its own device linked to the config-entry-level bot device.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + # The bot device belongs to the config entry (no subentry) and holds the event entity + bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) + assert bot_device is not None + assert bot_device.config_subentry_id is None + + for chat_id in (123456, 654321): + chat_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"123456_{chat_id}")} + ) + assert chat_device is not None + assert chat_device.config_subentry_id is not None + assert chat_device.via_device_id == bot_device.id + notify_entity_id = entity_registry.async_get_entity_id( + "notify", DOMAIN, f"123456_{chat_id}" + ) + assert notify_entity_id is not None + assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id + + +async def test_remove_chat_subentry_removes_per_chat_device( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Removing a chat subentry removes just its per-chat device and notify entity.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + subentry_id = next( + sid + for sid, subentry in mock_broadcast_config_entry.subentries.items() + if subentry.data[CONF_CHAT_ID] == 123456 + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + + hass.config_entries.async_remove_subentry(mock_broadcast_config_entry, subentry_id) + await hass.async_block_till_done() + + # The removed chat's device and notify entity are gone; the other chat and the bot + # device remain + assert not device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert not entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_654321")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py index 2305114127d9..ffa4daebff12 100644 --- a/tests/components/telegram_bot/test_notify.py +++ b/tests/components/telegram_bot/test_notify.py @@ -43,7 +43,7 @@ async def test_send_message( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", ATTR_MESSAGE: "mock message", ATTR_TITLE: "mock title", }, @@ -64,7 +64,7 @@ async def test_send_message( message_thread_id=None, ) - state = hass.states.get("notify.mock_title_mock_chat") + state = hass.states.get("notify.mock_chat") assert state assert state.state == "2025-01-09T12:00:00+00:00" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index a4592826d752..3e7fd2848508 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -224,7 +224,7 @@ async def test_send_message( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -322,7 +322,7 @@ async def test_send_message_with_inline_keyboard( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -368,9 +368,9 @@ async def test_send_sticker_partial_error( assert err.value.translation_key == "multiple_errors" assert err.value.translation_placeholders == { "errors": ( - "`entity_id` notify.mock_title_mock_chat_1:" + "`entity_id` notify.mock_chat_1:" " mock network error\n" - "`entity_id` notify.mock_title_mock_chat_2:" + "`entity_id` notify.mock_chat_2:" " mock network error" ) } @@ -588,7 +588,7 @@ async def test_send_file(hass: HomeAssistant, webhook_bot, service: str) -> None { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -1076,7 +1076,7 @@ async def test_send_message_with_config_entry( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1187,7 +1187,7 @@ async def test_delete_message( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1616,7 +1616,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1648,7 +1648,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1837,7 +1837,7 @@ async def test_send_message_multi_target( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1857,7 +1857,7 @@ async def test_notify_entity_send_message( response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, - {ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", ATTR_MESSAGE: "test_message"}, + {ATTR_ENTITY_ID: "notify.mock_chat_2", ATTR_MESSAGE: "test_message"}, blocking=True, return_response=True, ) @@ -1867,7 +1867,7 @@ async def test_notify_entity_send_message( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1921,7 +1921,7 @@ async def test_migrate_chat_id( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -2616,7 +2616,7 @@ async def test_send_media_group( "chats": [ { ATTR_CHAT_ID: 123456, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", ATTR_MESSAGE_ID: [12345, 12346, 12347, 12348], } ] diff --git a/tests/components/teltonika/snapshots/test_init.ambr b/tests/components/teltonika/snapshots/test_init.ambr index 6280477ceeaa..1b0158f00d79 100644 --- a/tests/components/teltonika/snapshots/test_init.ambr +++ b/tests/components/teltonika/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_creation DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://192.168.1.1', 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'RUTX50 Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'RUTX_R_00.07.17.3', 'via_device_id': None, diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 934c5f9ed92d..bbcdf7cfab25 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -321,7 +321,7 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - availability = {"advanced_options": {"availability": "{{ True }}"}} + availability = {"additional_options": {"availability": "{{ True }}"}} with patch( "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry @@ -1103,7 +1103,7 @@ async def test_config_flow_preview( assert result["preview"] == "template" availability = { - "advanced_options": { + "additional_options": { "availability": "{{ is_state('binary_sensor.available', 'on') }}" } } diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index 59eab709bf54..1eb3d54b490d 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -155,7 +155,7 @@ async def test_setup_config_entry( options={ "name": TEST_TRACKER.object_id, **TEST_MINIMUM_REQUIREMENTS, - "advanced_options": {"location_accuracy": "{{ 10 }}"}, + "additional_options": {"location_accuracy": "{{ 10 }}"}, "template_type": device_tracker.DOMAIN, }, title="My template", diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 5c27ef80248a..edd85ec0dad0 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -532,7 +532,7 @@ async def test_migration_1_1( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from v1.1 removes template config entry from device.""" + """Test migration from v1.1 does not add the template config entry to the device.""" device_config_entry = MockConfigEntry() device_config_entry.add_to_hass(hass) @@ -557,29 +557,53 @@ async def test_migration_1_1( ) template_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - device_entry.id, add_config_entry_id=template_config_entry.entry_id - ) - - # Check preconditions - device_entry = device_registry.async_get(device_entry.id) - assert template_config_entry.entry_id in device_entry.config_entries - await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() assert template_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # 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") assert template_entity_entry.device_id == device_entry.id - assert template_config_entry.version == 1 - assert template_config_entry.minor_version == 2 + assert template_config_entry.version == 2 + assert template_config_entry.minor_version == 1 + + +async def test_migration_1_2( + hass: HomeAssistant, +) -> None: + """Test migration from v1.2 renames the advanced_options section.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "template_type": "sensor", + "state": "{{ 'foo' }}", + "advanced_options": {"availability": "{{ True }}"}, + }, + title="My template", + version=1, + minor_version=2, + ) + template_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + assert template_config_entry.state is ConfigEntryState.LOADED + assert "advanced_options" not in template_config_entry.options + assert template_config_entry.options["additional_options"] == { + "availability": "{{ True }}" + } + + assert template_config_entry.version == 2 + assert template_config_entry.minor_version == 1 async def test_migration_from_future_version( @@ -595,7 +619,7 @@ async def test_migration_from_future_version( "state": "{{ 'foo' }}", }, title="My template", - version=2, + version=3, minor_version=1, ) config_entry.add_to_hass(hass) diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index 7ce999659005..7edbf70f56c0 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[{('tesla_fleet', '123456')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Energy Site', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[{('tesla_fleet', 'LRWXF7EK4KC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'LRWXF7EK4KC700000', 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[{('tesla_fleet', 'abd-123')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_devices[{('tesla_fleet', 'bcd-234')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '234', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index 722aacd989b8..a4b842e36a23 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[{('teslemetry', '123456')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console/energy/123456', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Energy Site', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': '23.44.0 eb113390', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[{('teslemetry', 'LRW3F7EK4NC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console/vehicle/LRW3F7EK4NC700000', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': '3', 'name': 'Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'LRW3F7EK4NC700000', 'sw_version': '2026.0.0', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[{('teslemetry', 'abd-123')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_devices[{('teslemetry', 'bcd-234')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '234', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 0f92a0c0e68e..92bbb62fcd95 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -265,18 +265,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, threshold_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the threshold config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() @@ -288,15 +280,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.threshold.async_unload_entry", wraps=threshold.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -305,6 +294,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") assert threshold_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -470,7 +462,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes threshold config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" threshold_config_entry = MockConfigEntry( data={}, @@ -488,22 +480,13 @@ async def test_migration_1_1( ) threshold_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() assert threshold_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # 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") diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index 9e2620313a0d..d86c2fccbbf3 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Wallet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '01.12.14.0', 'via_device_id': None, diff --git a/tests/components/todo/test_trigger.py b/tests/components/todo/test_trigger.py index b3af229f6948..e63493f1bb8b 100644 --- a/tests/components/todo/test_trigger.py +++ b/tests/components/todo/test_trigger.py @@ -93,8 +93,12 @@ def target_todo_lists( label_list_one = label_registry.async_create("label_list_one") label_list_two = label_registry.async_create("label_list_two") - device_list_one = dr.DeviceEntry(id="device_list_one") - device_list_two = dr.DeviceEntry(id="device_list_two") + device_list_one = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_one" + ) + device_list_two = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_two" + ) mock_device_registry( hass, { diff --git a/tests/components/togrill/snapshots/test_init.ambr b/tests/components/togrill/snapshots/test_init.ambr index e4208e702ccb..dab4387e538f 100644 --- a/tests/components/togrill/snapshots/test_init.ambr +++ b/tests/components/togrill/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup_device_present DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'Pro-05', 'name': 'Pro-05', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index e2e4f37c2621..17a67905ff30 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -419,8 +419,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -445,7 +445,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 4d0149b13483..b654ca41dc25 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -611,8 +611,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -637,7 +637,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index 0f9158c0e9d5..b6404ac7da79 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -55,8 +55,8 @@ # name: test_states[my_camera-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -81,7 +81,6 @@ 'model_id': None, 'name': 'my_camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index f40b2e512da3..bba51badaf66 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -69,8 +69,8 @@ # name: test_states[thermostat-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -91,7 +91,6 @@ 'model_id': None, 'name': 'thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': , diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index 993a8d671613..38818dc04a8f 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -173,8 +173,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -199,7 +199,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index f1fdeba67238..8b3dcca8e269 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index a5e70e452e57..6ae6dd424ae0 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 2c845448bb0b..b6163e9e0a52 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index b0dec2f49acc..06e036d4ddaa 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -2,8 +2,8 @@ # name: test_states[hub-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 3654e37c8b2b..e5137655d40b 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index 0d432cb0a014..a057c3523817 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -2,8 +2,8 @@ # name: test_states[my_vacuum-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py new file mode 100644 index 000000000000..6d830b6c8d6e --- /dev/null +++ b/tests/components/traccar/test_device_tracker.py @@ -0,0 +1,72 @@ +"""The tests for the Traccar device tracker platform.""" + +import pytest + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + TrackerEntityStateAttribute, +) +from homeassistant.components.device_tracker.legacy import Device +from homeassistant.components.traccar import DOMAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_WEBHOOK_ID, + STATE_NOT_HOME, + EntityStateAttribute, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_restore_cache + +DEVICE_ID = "device_1" +ENTITY_ID = f"{DEVICE_TRACKER_DOMAIN}.{DEVICE_ID}" + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: + """Mock device tracker config loading.""" + + +async def test_restore_state(hass: HomeAssistant) -> None: + """Test that the previous location is restored for a known device.""" + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, {}) + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEBHOOK_ID: "webhook_id"}) + entry.add_to_hass(hass) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, DEVICE_ID)}, + ) + + mock_restore_cache( + hass, + [ + State( + ENTITY_ID, + STATE_NOT_HOME, + { + EntityStateAttribute.LATITUDE: 1.0, + EntityStateAttribute.LONGITUDE: 2.0, + TrackerEntityStateAttribute.GPS_ACCURACY: 30, + ATTR_BATTERY_LEVEL: 40, + "altitude": 50, + "bearing": 60, + "speed": 70, + }, + ) + ], + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[EntityStateAttribute.LATITUDE] == 1.0 + assert state.attributes[EntityStateAttribute.LONGITUDE] == 2.0 + assert state.attributes[TrackerEntityStateAttribute.GPS_ACCURACY] == 30 + assert state.attributes[ATTR_BATTERY_LEVEL] == 40 + assert state.attributes["altitude"] == 50 + assert state.attributes["bearing"] == 60 + assert state.attributes["speed"] == 70 diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 689074c463fa..c6f9a783ef97 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -190,18 +190,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, trend_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the trend config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() @@ -213,15 +205,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.trend.async_unload_entry", wraps=trend.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -229,6 +218,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("binary_sensor.my_trend") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -394,7 +386,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes trend config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" trend_config_entry = MockConfigEntry( data={}, @@ -410,22 +402,13 @@ async def test_migration_1_1( ) trend_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=trend_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() assert trend_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # 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") diff --git a/tests/components/trmnl/snapshots/test_init.ambr b/tests/components/trmnl/snapshots/test_init.ambr index 64e84eda1a00..0da5c5be56b6 100644 --- a/tests/components/trmnl/snapshots/test_init.ambr +++ b/tests/components/trmnl/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Test TRMNL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 6c9cbee97bde..30be6ff71032 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry[0hlcxgoadnrh03yaqkydsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'ay30hrndaogxclh0', 'name': 'LCDÕ▒ŵ©®µ╣┐Õ║ªõ©çÞâ¢ÚüѵĺÕÖ¿', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_registry[0qtza8cv6q5rdxpxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'xpxdr5q6vc8aztq0', 'name': 'Weather station', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_registry[0wep74vtderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': '47pew0', 'name': 'TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_device_registry[18yvbamhgkjc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': 'hmabvy81', 'name': 'Interruptor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_device_registry[1nw1rysgyj8th1l5qbnxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': '5l1ht8jygsyr1wn1', 'name': 'Panneaux solaires 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_device_registry[2k8wyjo7iidkohuczc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': 'cuhokdii7ojyw8k2', 'name': 'Buitenverlichting', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_device_registry[2myxayqtud9aqbizsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': 'zibqa9dutqyaxym2', 'name': 'Dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_device_registry[2pxfek1jjrtctiyglam] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': 'gyitctrjj1kefxp2', 'name': 'Multifunction alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_device_registry[2w46jyhngklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': 'nhyj64w2', 'name': 'Tapparelle studio', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_device_registry[2x473nefusdo7af6zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': '6fa7odsufen374x2', 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_device_registry[3d4yosotwk27nqxvzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': 'vxqn72kwtosoy4d3', 'name': 'Garage Socket', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_device_registry[3kdnp0ajo7zdolfxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': 'xflodz7oja0pndk3', 'name': 'Sensor T & H Server Home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_device_registry[3phkffywh5nnlj5vbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': 'v5jlnn5hwyffkhp3', 'name': 'Production', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_device_registry[3uqk1csjqplf3uxqscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': 'qxu3flpqjsc1kqu3', 'name': 'Garage Contact Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_device_registry[49m7h9lh3t8pq6ftzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': 'tf6qp8t3hl9h7m94', 'name': 'Consommation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_device_registry[4bxfp3kgncpcgx5uycjzs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': 'u5xgcpcngk3pfxb4', 'name': 'YINMIK Water Quality Tester', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_device_registry[4fO1qIzYbcdMUHqAjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': 'AqHUMdcbYzIq1Of4', 'name': 'Landing', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_device_registry[4hbnivc4w2rsw966lc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': '669wsr2w4cvinbh4', 'name': 'VIVIDSTORM SCREEN', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_device_registry[4pa1uobdjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': 'dbou1ap4', 'name': 'Lumy Garage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_device_registry[4q5c2am8n1bwb6bszc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': 'sb6bwb1n8ma2c5q4', 'name': 'Socket4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_device_registry[51tdkcsamisw9ukycp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': 'yku9wsimasckdt15', 'name': 'Framboisier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_device_registry[53apxfah2qoxb1cgkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': 'gc1bxoq2hafxpa35', 'name': 'Полотенцосушитель', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_device_registry[53fnjncm3jywuaznps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': 'nzauwyj3mcnjnf35', 'name': 'Garage Camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_device_registry[5ebss29hqqmse7t5psm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': '5t7esmqqh92ssbe5', 'name': 'Slimme kattenbak', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +722,8 @@ # name: test_device_registry[5gfyvvg48bsxbbnjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -768,7 +744,6 @@ 'model_id': 'jnbbxsb84gvvyfg5', 'name': 'Bathroom Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -777,8 +752,8 @@ # name: test_device_registry[63cninaczt9dwo7v2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -799,7 +774,6 @@ 'model_id': 'v7owd9tzcaninc36', 'name': 'Gateway2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -808,8 +782,8 @@ # name: test_device_registry[69dth3rxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -830,7 +804,6 @@ 'model_id': 'xr3htd96', 'name': 'Humy toilettes RDC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -839,8 +812,8 @@ # name: test_device_registry[6ffyxwrjsuydxhqrqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -861,7 +834,6 @@ 'model_id': 'rqhxdyusjrwxyff6', 'name': 'Smart IR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -870,8 +842,8 @@ # name: test_device_registry[6gsqieoh1yzjvxlnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -892,7 +864,6 @@ 'model_id': 'nlxvjzy1hoeiqsg6', 'name': 'hall 💡 ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -901,8 +872,8 @@ # name: test_device_registry[6h8boeqxorpsmtj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -923,7 +894,6 @@ 'model_id': 'xqeob8h6', 'name': 'S1-TY-BLE-PRO 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -932,8 +902,8 @@ # name: test_device_registry[6o148laaosbf0g4djd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -954,7 +924,6 @@ 'model_id': 'd4g0fbsoaal841o6', 'name': 'WC D1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -963,8 +932,8 @@ # name: test_device_registry[6pd3bkidqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -985,7 +954,6 @@ 'model_id': 'dikb3dp6', 'name': 'Medidor de Energia', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -994,8 +962,8 @@ # name: test_device_registry[6tbtkuv3tal1aesfjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1016,7 +984,6 @@ 'model_id': 'fsea1lat3vuktbt6', 'name': 'BR 7-in-1 WLAN Wetterstation Anthrazit', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1025,8 +992,8 @@ # name: test_device_registry[6wxksqu35c61sce9dsf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1047,7 +1014,6 @@ 'model_id': '9ecs16c53uqskxw6', 'name': 'ceiling fan/Light v2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1056,8 +1022,8 @@ # name: test_device_registry[73ov8i8iedtylkzrqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1078,7 +1044,6 @@ 'model_id': 'rzklytdei8i8vo37', 'name': 'balkonbewässerung', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1087,8 +1052,8 @@ # name: test_device_registry[7axah58vfydd8cphjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1109,7 +1074,6 @@ 'model_id': 'hpc8ddyfv85haxa7', 'name': 'Garage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1118,8 +1082,8 @@ # name: test_device_registry[7jxnjpiltmj2zyaijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1140,7 +1104,6 @@ 'model_id': 'iayz2jmtlipjnxj7', 'name': 'LED Porch 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1112,8 @@ # name: test_device_registry[7obpyhy8scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1171,7 +1134,6 @@ 'model_id': '8yhypbo7', 'name': 'Boîte aux lettres - arrière', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1180,8 +1142,8 @@ # name: test_device_registry[7xpq8plg06p46j7ygklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1202,7 +1164,6 @@ 'model_id': 'y7j64p60glp8qpx7', 'name': 'Fenster Küche', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1211,8 +1172,8 @@ # name: test_device_registry[7zogt3pcwhxhu8upqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1233,7 +1194,6 @@ 'model_id': 'pu8uhxhwcp3tgoz7', 'name': 'Socket3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1242,8 +1202,8 @@ # name: test_device_registry[86kdcut3hiqqddlijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1264,7 +1224,6 @@ 'model_id': 'ilddqqih3tucdk68', 'name': 'Ieskas', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1273,8 +1232,8 @@ # name: test_device_registry[87yarxyp23ap1vazjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1295,7 +1254,6 @@ 'model_id': 'zav1pa32pyxray78', 'name': 'Gengske 💡 ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1304,8 +1262,8 @@ # name: test_device_registry[8m3ggyvgycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1326,7 +1284,6 @@ 'model_id': 'gvygg3m8', 'name': 'humid pelargonia', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1335,8 +1292,8 @@ # name: test_device_registry[8u5ftxkt52smougesc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1357,7 +1314,6 @@ 'model_id': 'eguoms25tkxtf5u8', 'name': 'Arida Stavern ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1366,8 +1322,8 @@ # name: test_device_registry[97k3pwirjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1388,7 +1344,6 @@ 'model_id': 'riwp3k79', 'name': 'LED KEUKEN 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1397,8 +1352,8 @@ # name: test_device_registry[9AzrW5XtELTySJxqzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1419,7 +1374,6 @@ 'model_id': 'qxJSyTLEtX5WrzA9', 'name': 'LivR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1428,8 +1382,8 @@ # name: test_device_registry[9Ry4oUpdAYq8Pe0Bkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1450,7 +1404,6 @@ 'model_id': 'B0eP8qYAdpUo4yR9', 'name': 'ITC-308-WIFI Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1459,8 +1412,8 @@ # name: test_device_registry[9c1vlsxoscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1481,7 +1434,6 @@ 'model_id': 'oxslv1c9', 'name': 'Window downstairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1490,8 +1442,8 @@ # name: test_device_registry[9oh1h1uyalfykgg4bdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1512,7 +1464,6 @@ 'model_id': '4ggkyflayu1h1ho9', 'name': 'XOCA-DAC212XC V2-S1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1521,8 +1472,8 @@ # name: test_device_registry[9wlo8cpzprhiclrkgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1543,7 +1494,6 @@ 'model_id': 'krlcihrpzpc8olw9', 'name': 'IFS-STD002', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1552,8 +1502,8 @@ # name: test_device_registry[AUTwCwqDY9EjlQSocm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1574,7 +1524,6 @@ 'model_id': 'oSQljE9YDqwCwTUA', 'name': 'Kippenluik', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1583,8 +1532,8 @@ # name: test_device_registry[CyD4ctKVrAFSSXSbjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1605,7 +1554,6 @@ 'model_id': 'bSXSSFArVKtc4DyC', 'name': 'bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1614,8 +1562,8 @@ # name: test_device_registry[HzsAAAKFLPABVi8nzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1636,7 +1584,6 @@ 'model_id': 'n8iVBAPLFKAAAszH', 'name': 'Steckdose 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1645,8 +1592,8 @@ # name: test_device_registry[JLWRUpPiwMTwKXtTtq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1667,7 +1614,6 @@ 'model_id': 'TtXKwTMwiPpURWLJ', 'name': 'Dining-Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1676,8 +1622,8 @@ # name: test_device_registry[LJ9zTFQTfMgsG2Ahzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1698,7 +1644,6 @@ 'model_id': 'hA2GsgMfTQFTz9JL', 'name': 'Spot 4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1707,8 +1652,8 @@ # name: test_device_registry[LS6FfVBVU1vzBRBHzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1729,7 +1674,6 @@ 'model_id': 'HBRBzv1UVBVfF6SL', 'name': 'Rewireable Plug 6930HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1738,8 +1682,8 @@ # name: test_device_registry[LmLMc0ht1KW2zYAIkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1760,7 +1704,6 @@ 'model_id': 'IAYz2WK1th0cMLmL', 'name': 'El termostato de la cocina', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1769,8 +1712,8 @@ # name: test_device_registry[NVjuXIQ6QH9eZLHCzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1791,7 +1734,6 @@ 'model_id': 'CHLZe9HQ6QIXujVN', 'name': 'schuur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1800,8 +1742,8 @@ # name: test_device_registry[O8QpxJwdme33sqn4gk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1822,7 +1764,6 @@ 'model_id': '4nqs33emdwJxpQ8O', 'name': 'office lights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1831,8 +1772,8 @@ # name: test_device_registry[VA4QyBNZHkJ2Xa4hjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1853,7 +1794,6 @@ 'model_id': 'h4aX2JkHZNByQ4AV', 'name': 'Entry Stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1862,8 +1802,8 @@ # name: test_device_registry[YQLkAe7nyyAxXHiAzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1884,7 +1824,6 @@ 'model_id': 'AiHXxAyyn7eAkLQY', 'name': 'Solar Heater Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1893,8 +1832,8 @@ # name: test_device_registry[ZDldMHS0tjmQgGxEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1915,7 +1854,6 @@ 'model_id': 'ExGgQmjt0SHMdlDZ', 'name': 'Casa1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1924,8 +1862,8 @@ # name: test_device_registry[ZgXzZULP6dDp4Atvgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1946,7 +1884,6 @@ 'model_id': 'vtA4pDd6PLUZzXgZ', 'name': 'Humy bain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1955,8 +1892,8 @@ # name: test_device_registry[a3qtb7pulkcc6jdjqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1977,7 +1914,6 @@ 'model_id': 'jdj6ccklup7btq3a', 'name': 'Eau Chaude', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1986,8 +1922,8 @@ # name: test_device_registry[a4zeazrz1ata9mbggk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2008,7 +1944,6 @@ 'model_id': 'gbm9ata1zrzaez4a', 'name': 'QT-Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2017,8 +1952,8 @@ # name: test_device_registry[a6ugbo3of3hqf4jojd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2039,7 +1974,6 @@ 'model_id': 'oj4fqh3fo3obgu6a', 'name': 'L├ímpara Ati', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2048,8 +1982,8 @@ # name: test_device_registry[aa99hccfnzvypr3zjsywc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2070,7 +2004,6 @@ 'model_id': 'z3rpyvznfcch99aa', 'name': 'PIXI Smart Drinking Fountain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2079,8 +2012,8 @@ # name: test_device_registry[addr6y4u8gb43nl8brnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2101,7 +2034,6 @@ 'model_id': '8ln34bg8u4y6rdda', 'name': 'Madimack Elite V3 Pool Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2110,8 +2042,8 @@ # name: test_device_registry[ai9swgb6tyinbwbxjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2132,7 +2064,6 @@ 'model_id': 'xbwbniyt6bgws9ia', 'name': 'SWS 16600 WiFi SH', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2141,8 +2072,8 @@ # name: test_device_registry[aiag5pku0x39rkfllc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2163,7 +2094,6 @@ 'model_id': 'lfkr93x0ukp5gaia', 'name': 'Projector Screen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2172,8 +2102,8 @@ # name: test_device_registry[aje5kxgmhhxdihqizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2194,7 +2124,6 @@ 'model_id': 'iqhidxhhmgxk5eja', 'name': 'Powerplug 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2203,8 +2132,8 @@ # name: test_device_registry[ajkdo1bm2rcmpuufjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2225,7 +2154,6 @@ 'model_id': 'fuupmcr2mb1odkja', 'name': 'Slaapkamer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2234,8 +2162,8 @@ # name: test_device_registry[ake0bre784zriw0usc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2256,7 +2184,6 @@ 'model_id': 'u0wirz487erb0eka', 'name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2265,8 +2192,8 @@ # name: test_device_registry[ao3z3oeyvepe8o3xqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2287,7 +2214,6 @@ 'model_id': 'x3o8epevyeo3z3oa', 'name': 'Interior Bedroom Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2296,8 +2222,8 @@ # name: test_device_registry[aoyweq8xbx7qfndijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2318,7 +2244,6 @@ 'model_id': 'idnfq7xbx8qewyoa', 'name': 'AB1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2327,8 +2252,8 @@ # name: test_device_registry[ase6htln9tdni2sijxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2349,7 +2274,6 @@ 'model_id': 'is2indt9nlth6esa', 'name': 'Frysen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2358,8 +2282,8 @@ # name: test_device_registry[b6e05dfy4qhpgea1qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2380,7 +2304,6 @@ 'model_id': '1aegphq4yfd50e6b', 'name': 'jardin Fraises', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2389,8 +2312,8 @@ # name: test_device_registry[bFFsO8HimyAJGIj7scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2411,7 +2334,6 @@ 'model_id': '7jIGJAymiH8OsFFb', 'name': 'Door Garage ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2420,8 +2342,8 @@ # name: test_device_registry[bak2crzmabancwqvjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2442,7 +2364,6 @@ 'model_id': 'vqwcnabamzrc2kab', 'name': 'Strip 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2451,8 +2372,8 @@ # name: test_device_registry[bcyciyhhu1g2gk9rqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2473,7 +2394,6 @@ 'model_id': 'r9kg2g1uhhyicycb', 'name': 'P1 Energia Elettrica', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2482,8 +2402,8 @@ # name: test_device_registry[bescacsciyam3aouqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2504,7 +2424,6 @@ 'model_id': 'uoa3mayicscacseb', 'name': 'Living room left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2513,8 +2432,8 @@ # name: test_device_registry[bfpewgk8r6fhmissdyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2535,7 +2454,6 @@ 'model_id': 'ssimhf6r8kgwepfb', 'name': 'BlissRadia ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2544,8 +2462,8 @@ # name: test_device_registry[bgnj6bafrdgb1xmajd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2566,7 +2484,6 @@ 'model_id': 'amx1bgdrfab6jngb', 'name': 'Lumy Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2575,8 +2492,8 @@ # name: test_device_registry[bjum5isf7h6xpbrvzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2597,7 +2514,6 @@ 'model_id': 'vrbpx6h7fsi5mujb', 'name': '接HA双向计量插座', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2606,8 +2522,8 @@ # name: test_device_registry[bl5cuqxnqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2628,7 +2544,6 @@ 'model_id': 'nxquc5lb', 'name': 'Smart Water Timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2637,8 +2552,8 @@ # name: test_device_registry[btpss2f6kwfi294rqsj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2659,7 +2574,6 @@ 'model_id': 'r492ifwk6f2ssptb', 'name': 'KLARTA HUMEA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2668,8 +2582,8 @@ # name: test_device_registry[btyk53n3v10z7a97zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2690,7 +2604,6 @@ 'model_id': '79a7z01v3n35kytb', 'name': 'Double Digital Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2699,8 +2612,8 @@ # name: test_device_registry[buzituffc13pgb1jjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2721,7 +2634,6 @@ 'model_id': 'j1bgp31cffutizub', 'name': 'Ceiling Portal', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2730,8 +2642,8 @@ # name: test_device_registry[bxfkpxjgux2fgwnazc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2752,7 +2664,6 @@ 'model_id': 'anwgf2xugjxpkfxb', 'name': 'Security Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2761,8 +2672,8 @@ # name: test_device_registry[c1tfgunpf6optybisf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2783,7 +2694,6 @@ 'model_id': 'ibytpo6fpnugft1c', 'name': 'Ventilador Cama', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2792,8 +2702,8 @@ # name: test_device_registry[c9nbmrweturkgqktdyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2814,7 +2724,6 @@ 'model_id': 'tkqgkrutewrmbn9c', 'name': 'White Noise Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2823,8 +2732,8 @@ # name: test_device_registry[cd6bezcadvjngj5jrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2845,7 +2754,6 @@ 'model_id': 'j5jgnjvdaczeb6dc', 'name': 'QNECT WI-FI PIR SENSOR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2854,8 +2762,8 @@ # name: test_device_registry[cijerqyssiwrf7deqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2876,7 +2784,6 @@ 'model_id': 'ed7frwissyqrejic', 'name': '接HA水阀', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2885,8 +2792,8 @@ # name: test_device_registry[cju47ovcbeuapei2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2907,7 +2814,6 @@ 'model_id': '2iepauebcvo74ujc', 'name': 'Aubess Cooker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2916,8 +2822,8 @@ # name: test_device_registry[codvtvgtjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2938,7 +2844,6 @@ 'model_id': 'tgvtvdoc', 'name': 'Tournesol', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2947,8 +2852,8 @@ # name: test_device_registry[couukaypjdnyt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2969,7 +2874,6 @@ 'model_id': 'pyakuuoc', 'name': 'Solar zijpad', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2978,8 +2882,8 @@ # name: test_device_registry[cq4hzlrnqn4qi0mqzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3000,7 +2904,6 @@ 'model_id': 'qm0iq4nqnrlzh4qc', 'name': 'Elivco Kitchen Socket', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3009,8 +2912,8 @@ # name: test_device_registry[cvowstbid97lokayjb2oc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3031,7 +2934,6 @@ 'model_id': 'yakol79dibtswovc', 'name': 'PTH-9CW 32', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3040,8 +2942,8 @@ # name: test_device_registry[cwwk68dyfsh2eqi4jbqr] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3062,7 +2964,6 @@ 'model_id': '4iqe2hsfyd86kwwc', 'name': 'Gas sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3071,8 +2972,8 @@ # name: test_device_registry[cxbmhihohohk5bmeqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3093,7 +2994,6 @@ 'model_id': 'emb5khohohihmbxc', 'name': 'Server Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3102,8 +3002,8 @@ # name: test_device_registry[dBFBdywk9gTihUQmzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3124,7 +3024,6 @@ 'model_id': 'mQUhiTg9kwydBFBd', 'name': 'Waschmaschine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3133,8 +3032,8 @@ # name: test_device_registry[dNBnmtjLU8eRWHf0zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3155,7 +3054,6 @@ 'model_id': '0fHWRe8ULjtmnBNd', 'name': 'Weihnachten3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3164,8 +3062,8 @@ # name: test_device_registry[dj8foneugkjc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3186,7 +3084,6 @@ 'model_id': 'uenof8jd', 'name': 'Smart Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3195,8 +3092,8 @@ # name: test_device_registry[dke76hazlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3217,7 +3114,6 @@ 'model_id': 'zah67ekd', 'name': 'Kitchen Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3226,8 +3122,8 @@ # name: test_device_registry[dn7cjik6kw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3248,7 +3144,6 @@ 'model_id': '6kijc7nd', 'name': 'Кабінет', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3257,8 +3152,8 @@ # name: test_device_registry[dt4whlrosmnldadvtk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3279,7 +3174,6 @@ 'model_id': 'vdadlnmsorlhw4td', 'name': 'Sove', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3288,8 +3182,8 @@ # name: test_device_registry[dvdtmcoil5yopaljjzm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3310,7 +3204,6 @@ 'model_id': 'jlapoy5liocmtdvd', 'name': 'ISV-100W2.0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3319,8 +3212,8 @@ # name: test_device_registry[e2sbdwuga5jorvejtkdy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3341,7 +3234,6 @@ 'model_id': 'jevroj5aguwdbs2e', 'name': 'DOLCECLIMA 10 HP WIFI', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3350,8 +3242,8 @@ # name: test_device_registry[ej2zsznihehztkzqcaderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3372,7 +3264,6 @@ 'model_id': 'qzktzhehinzsz2je', 'name': 'Air', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3381,8 +3272,8 @@ # name: test_device_registry[eway2kw92ncuecarzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3403,7 +3294,6 @@ 'model_id': 'raceucn29wk2yawe', 'name': 'Bathroom Mirror', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3412,8 +3302,8 @@ # name: test_device_registry[f4vvhmhvseuiqs6pqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3434,7 +3324,6 @@ 'model_id': 'p6sqiuesvhmhvv4f', 'name': 'Entrance Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3443,8 +3332,8 @@ # name: test_device_registry[fasvixqysw1lxvjprd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3465,7 +3354,6 @@ 'model_id': 'pjvxl1wsyqxivsaf', 'name': 'Sunbeam Bedding', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3474,8 +3362,8 @@ # name: test_device_registry[fbya6s6rhaoyvl8hqgcwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3496,7 +3384,6 @@ 'model_id': 'h8lvyoahr6s6aybf', 'name': 'Rainwater Tank Level', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3505,8 +3392,8 @@ # name: test_device_registry[fc2ngmpckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3527,7 +3414,6 @@ 'model_id': 'cpmgn2cf', 'name': 'Bathroom radiator', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3536,8 +3422,8 @@ # name: test_device_registry[fcacn8iqbocuow7dsr] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3558,7 +3444,6 @@ 'model_id': 'd7woucobqi8ncacf', 'name': 'Geti Solar PV Water Heater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3567,8 +3452,8 @@ # name: test_device_registry[fcdadqsiax2gvnt0qld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3589,7 +3474,6 @@ 'model_id': '0tnvg2xaisqdadcf', 'name': '一路带计量磁保持通断器', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3598,8 +3482,8 @@ # name: test_device_registry[fjdyw5ld2f5f5ddsps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3620,7 +3504,6 @@ 'model_id': 'sdd5f5f2dl5wydjf', 'name': 'C9', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3629,8 +3512,8 @@ # name: test_device_registry[fov1huugujgfyl0xqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3651,7 +3534,6 @@ 'model_id': 'x0lyfgjuguuh1vof', 'name': 'Smart IR+RF Remote Control', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3660,8 +3542,8 @@ # name: test_device_registry[frmfrbds0jixxyaljbngd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3682,7 +3564,6 @@ 'model_id': 'layxxij0sdbrfmrf', 'name': 'WiFi smart online 8 in 1 tester', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3691,8 +3572,8 @@ # name: test_device_registry[ftvxinxevpy21tbelc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3713,7 +3594,6 @@ 'model_id': 'ebt12ypvexnixvtf', 'name': 'Kitchen Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3722,8 +3602,8 @@ # name: test_device_registry[fvywp3b5mu4zay8lgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3744,7 +3624,6 @@ 'model_id': 'l8yaz4um5b3pwyvf', 'name': 'Bathroom Smart Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3753,8 +3632,8 @@ # name: test_device_registry[g0edqq0wzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3775,7 +3654,6 @@ 'model_id': 'w0qqde0g', 'name': 'Lave linge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3784,8 +3662,8 @@ # name: test_device_registry[g1efxsqnp33cg8r3lc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3806,7 +3684,6 @@ 'model_id': '3r8gc33pnqsxfe1g', 'name': 'Lounge Dark Blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3815,8 +3692,8 @@ # name: test_device_registry[g1fmm26qhhrimmbitk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3837,7 +3714,6 @@ 'model_id': 'ibmmirhhq62mmf1g', 'name': 'Master Bedroom AC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3846,8 +3722,8 @@ # name: test_device_registry[g1qorlffoy2iyo9bsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3868,7 +3744,6 @@ 'model_id': 'b9oyi2yofflroq1g', 'name': 'Living room dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3877,8 +3752,8 @@ # name: test_device_registry[g5uso5ajgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3899,7 +3774,6 @@ 'model_id': 'ja5osu5g', 'name': 'Bouton tempo extérieur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3908,8 +3782,8 @@ # name: test_device_registry[g7af6lrt4miugbstcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3930,7 +3804,6 @@ 'model_id': 'tsbguim4trl6fa7g', 'name': 'Keller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3939,8 +3812,8 @@ # name: test_device_registry[g9h9sblxpb5wdwzkqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3961,7 +3834,6 @@ 'model_id': 'kzwdw5bpxlbs9h9g', 'name': 'IR Minero', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3970,8 +3842,8 @@ # name: test_device_registry[gbq8kiahk57ct0bpncjynx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3992,7 +3864,6 @@ 'model_id': 'pb0tc75khaik8qbg', 'name': 'CBE Pro 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4001,8 +3872,8 @@ # name: test_device_registry[ggimpv4dqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4023,7 +3894,6 @@ 'model_id': 'd4vpmigg', 'name': 'Garden Valve Yard', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4032,8 +3902,8 @@ # name: test_device_registry[ggwxkj8bwn5y63flgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4054,7 +3924,6 @@ 'model_id': 'lf36y5nwb8jkxwgg', 'name': 'Greenhouse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4063,8 +3932,8 @@ # name: test_device_registry[gi69tunb0esxcnefzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4085,7 +3954,6 @@ 'model_id': 'fencxse0bnut96ig', 'name': 'Spa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4094,8 +3962,8 @@ # name: test_device_registry[giqs1xhsekjelfibsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4116,7 +3984,6 @@ 'model_id': 'biflejkeshx1sqig', 'name': 'D825A I', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4125,8 +3992,8 @@ # name: test_device_registry[gjnpc0eojd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4147,7 +4014,6 @@ 'model_id': 'oe0cpnjg', 'name': 'Front right Lighting trap', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4156,8 +4022,8 @@ # name: test_device_registry[glsehgu8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4178,7 +4044,6 @@ 'model_id': '8ugheslg', 'name': 'POWERASIA R2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4187,8 +4052,8 @@ # name: test_device_registry[gluaktf5gk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4209,7 +4074,6 @@ 'model_id': '5ftkaulg', 'name': 'bathroom light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4218,8 +4082,8 @@ # name: test_device_registry[gm0whbftkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4240,7 +4104,6 @@ 'model_id': 'tfbhw0mg', 'name': 'Salon', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4249,8 +4112,8 @@ # name: test_device_registry[gnZOKztbAtcBkEGPzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4271,7 +4134,6 @@ 'model_id': 'PGEkBctAbtzKOZng', 'name': 'Din', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4280,8 +4142,8 @@ # name: test_device_registry[gnqwzcph94wj2sl5nq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4302,7 +4164,6 @@ 'model_id': '5ls2jw49hpczwqng', 'name': 'Mr. Pure', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4311,8 +4172,8 @@ # name: test_device_registry[gt1q9tldv1opojrtcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4333,7 +4194,6 @@ 'model_id': 'trjopo1vdlt9q1tg', 'name': 'Terras', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4342,8 +4202,8 @@ # name: test_device_registry[gtcinipmdp5rgx3nlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4364,7 +4224,6 @@ 'model_id': 'n3xgr5pdmpinictg', 'name': 'Estore Sala', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4373,8 +4232,8 @@ # name: test_device_registry[gvxxy4jitzltz5xhscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4395,7 +4254,6 @@ 'model_id': 'hx5ztlztij4yxxvg', 'name': 'Steel cage door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4404,8 +4262,8 @@ # name: test_device_registry[hfqeljop3aihnm73zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4426,7 +4284,6 @@ 'model_id': '37mnhia3pojleqfh', 'name': 'Sapphire ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4435,8 +4292,8 @@ # name: test_device_registry[hkm4px9ohzozxma3rip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4457,7 +4314,6 @@ 'model_id': '3amxzozho9xp4mkh', 'name': 'rat trap hedge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4466,8 +4322,8 @@ # name: test_device_registry[hxbonj4yzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4488,7 +4344,6 @@ 'model_id': 'y4jnobxh', 'name': 'AuVeLiCo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4497,8 +4352,8 @@ # name: test_device_registry[hyda5jsihokacvaqjzm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4519,7 +4374,6 @@ 'model_id': 'qavcakohisj5adyh', 'name': 'Sous Vide', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4528,8 +4382,8 @@ # name: test_device_registry[hz4pau766eavmxhqsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4550,7 +4404,6 @@ 'model_id': 'qhxmvae667uap4zh', 'name': 'DryFix', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4559,8 +4412,8 @@ # name: test_device_registry[i6xywcsymer1kmb6ps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4581,7 +4434,6 @@ 'model_id': '6bmk1remyscwyx6i', 'name': 'Mirilla puerta', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4590,8 +4442,8 @@ # name: test_device_registry[iaagy4qigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4612,7 +4464,6 @@ 'model_id': 'iq4ygaai', 'name': 'Bassin', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4621,8 +4472,8 @@ # name: test_device_registry[idztlaspsms815moqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4643,7 +4494,6 @@ 'model_id': 'om518smspsaltzdi', 'name': 'Smart IR Theater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4652,8 +4502,8 @@ # name: test_device_registry[ifzgvpgoodrfw2aksc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4674,7 +4524,6 @@ 'model_id': 'ka2wfrdoogpvgzfi', 'name': 'Dehumidifer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4683,8 +4532,8 @@ # name: test_device_registry[igkrtodqg14xvfxlqswwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4705,7 +4554,6 @@ 'model_id': 'lxfvx41gqdotrkgi', 'name': 'Cat Feeder', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4714,8 +4562,8 @@ # name: test_device_registry[ijne16zv8vpqmubnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4736,7 +4584,6 @@ 'model_id': 'nbumqpv8vz61enji', 'name': 'b2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4745,8 +4592,8 @@ # name: test_device_registry[ijzjlqwmv1blwe0gsf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4767,7 +4614,6 @@ 'model_id': 'g0ewlb1vmwqljzji', 'name': 'Ceiling Fan With Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4776,8 +4622,8 @@ # name: test_device_registry[ikbbdbnqsd70pc1glc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4798,7 +4644,6 @@ 'model_id': 'g1cp07dsqnbdbbki', 'name': 'Persiana do Quarto', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4807,8 +4652,8 @@ # name: test_device_registry[iks13mcaiyie3rryjb2oc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4829,7 +4674,6 @@ 'model_id': 'yrr3eiyiacm31ski', 'name': 'AQI', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4838,8 +4682,8 @@ # name: test_device_registry[ilms5pwjzzsxuxmvsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4860,7 +4704,6 @@ 'model_id': 'vmxuxszzjwp5smli', 'name': 'Dehumidifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4869,8 +4712,8 @@ # name: test_device_registry[im3fum2zt73boagkjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4891,7 +4734,6 @@ 'model_id': 'kgaob37tz2muf3mi', 'name': 'Parker Ceiling Fan 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4900,8 +4742,8 @@ # name: test_device_registry[ingdwog22gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4922,7 +4764,6 @@ 'model_id': '2gowdgni', 'name': 'Mesh-Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4931,8 +4772,8 @@ # name: test_device_registry[iomszlsve0yyzkfwqswwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4953,7 +4794,6 @@ 'model_id': 'wfkzyy0evslzsmoi', 'name': 'Cleverio PF100', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4962,8 +4802,8 @@ # name: test_device_registry[j6mn1t4ut5end6ifkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4984,7 +4824,6 @@ 'model_id': 'fi6dne5tu4t1nm6j', 'name': 'WiFi Smart Gas Boiler Thermostat ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4993,8 +4832,8 @@ # name: test_device_registry[jfpdpavoqgoqsn3cjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5015,7 +4854,6 @@ 'model_id': 'c3nsqogqovapdpfj', 'name': 'Arbeitszimmer led', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5024,8 +4862,8 @@ # name: test_device_registry[jfydgffzmhjed9fgjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5046,7 +4884,6 @@ 'model_id': 'gf9dejhmzffgdyfj', 'name': ' Smoke detector upstairs ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5055,8 +4892,8 @@ # name: test_device_registry[jgsopsvzh2ec3itjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5077,7 +4914,6 @@ 'model_id': 'jti3ce2hzvsposgj', 'name': 'Dehumidifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5086,8 +4922,8 @@ # name: test_device_registry[jlduh7vigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5108,7 +4944,6 @@ 'model_id': 'iv7hudlj', 'name': 'Basement temperature', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5117,8 +4952,8 @@ # name: test_device_registry[jm2fsqtzuhqtbo5ykw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5139,7 +4974,6 @@ 'model_id': 'y5obtqhuztqsf2mj', 'name': 'Term - Prizemi', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5148,8 +4982,8 @@ # name: test_device_registry[jzpap0inhkykqtlwgklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5170,7 +5004,6 @@ 'model_id': 'wltqkykhni0papzj', 'name': 'Roller shutter Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5179,8 +5012,8 @@ # name: test_device_registry[kcdngswaxs8hm52bnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5201,7 +5034,6 @@ 'model_id': 'b25mh8sxawsgndck', 'name': 'ZigBee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5210,8 +5042,8 @@ # name: test_device_registry[kffnst1epj6vr8xnzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5232,7 +5064,6 @@ 'model_id': 'nx8rv6jpe1tsnffk', 'name': 'Spot 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5241,8 +5072,8 @@ # name: test_device_registry[kjr0pqg7eunn4vlujbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5263,7 +5094,6 @@ 'model_id': 'ulv4nnue7gqp0rjk', 'name': 'Siren veranda ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5272,8 +5102,8 @@ # name: test_device_registry[kkande5hk6sfdkoxjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5294,7 +5124,6 @@ 'model_id': 'xokdfs6kh5ednakk', 'name': 'ERKER 1-Gold ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5303,8 +5132,8 @@ # name: test_device_registry[kkcwqzlvgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5325,7 +5154,6 @@ 'model_id': 'vlzqwckk', 'name': 'Temperature Humidity Sensor abelhas pasillo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5334,8 +5162,8 @@ # name: test_device_registry[kkgbskmfejn67l1orip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5356,7 +5184,6 @@ 'model_id': 'o1l76njefmksbgkk', 'name': 'PIR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5365,8 +5192,8 @@ # name: test_device_registry[klgxmpwvdhw7tzs8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5387,7 +5214,6 @@ 'model_id': '8szt7whdvwpmxglk', 'name': 'Porch light E', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5396,8 +5222,8 @@ # name: test_device_registry[ksy8guiy64acbbpnqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5418,7 +5244,6 @@ 'model_id': 'npbbca46yiug8ysk', 'name': 'Bedroom IR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5427,8 +5252,8 @@ # name: test_device_registry[kta28zbwj6u0xa6lbsgy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5449,7 +5274,6 @@ 'model_id': 'l6ax0u6jwbz82atk', 'name': 'Pond', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5458,8 +5282,8 @@ # name: test_device_registry[kvnsoqyfltmf0bknzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5480,7 +5304,6 @@ 'model_id': 'nkb0fmtlfyqosnvk', 'name': 'Bassin', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5489,8 +5312,8 @@ # name: test_device_registry[kx8dncf1qzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5511,7 +5334,6 @@ 'model_id': '1fcnd8xk', 'name': 'Valve Controller 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5520,8 +5342,8 @@ # name: test_device_registry[kxwleaa2sph] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5542,7 +5364,6 @@ 'model_id': '2aaelwxk', 'name': 'Human presence Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5551,8 +5372,8 @@ # name: test_device_registry[kxxrbv93k2vvkconqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5573,7 +5394,6 @@ 'model_id': 'nockvv2k39vbrxxk', 'name': 'Seating side 6-ch Smart Switch ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5582,8 +5402,8 @@ # name: test_device_registry[l8uxezzkc7c5a0jhzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5604,7 +5424,6 @@ 'model_id': 'hj0a5c7ckzzexu8l', 'name': 'droger', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5613,8 +5432,8 @@ # name: test_device_registry[lflvu8cazha8af9jsk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5635,7 +5454,6 @@ 'model_id': 'j9fa8ahzac8uvlfl', 'name': 'Tower Fan CA-407G Smart', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5644,8 +5462,8 @@ # name: test_device_registry[llw1rhcau4y3othdzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5666,7 +5484,6 @@ 'model_id': 'dhto3y4uachr1wll', 'name': 'Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5675,8 +5492,8 @@ # name: test_device_registry[lnjsbx45z3p7s59zbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5697,7 +5514,6 @@ 'model_id': 'z95s7p3z54xbsjnl', 'name': 'WIFI Dual Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5706,8 +5522,8 @@ # name: test_device_registry[mgcpxpmovasazerdps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5728,7 +5544,6 @@ 'model_id': 'drezasavompxpcgm', 'name': 'CAM GARAGE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5737,8 +5552,8 @@ # name: test_device_registry[mjhwalv51czt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5759,7 +5574,6 @@ 'model_id': '5vlawhjm', 'name': 'INTELAR IR288', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5768,8 +5582,8 @@ # name: test_device_registry[mpowx36sgqexmtes2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5790,7 +5604,6 @@ 'model_id': 'setmxeqgs63xwopm', 'name': 'Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5799,8 +5612,8 @@ # name: test_device_registry[mvsdcwtskkezlnw5tk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5821,7 +5634,6 @@ 'model_id': '5wnlzekkstwcdsvm', 'name': 'Air Conditioner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5830,8 +5642,8 @@ # name: test_device_registry[mwsaod7fa3gjyh6ids] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5852,7 +5664,6 @@ 'model_id': 'i6hyjg3af7doaswm', 'name': 'Hoover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5861,8 +5672,8 @@ # name: test_device_registry[nc4e9nlZPTuTNfYEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5883,7 +5694,6 @@ 'model_id': 'EYfNTuTPZln9e4cn', 'name': 'ZAS-01', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5892,8 +5702,8 @@ # name: test_device_registry[ncl7oi5d6hqmf1g0zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5914,7 +5724,6 @@ 'model_id': '0g1fmqh6d5io7lcn', 'name': 'Apollo light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5923,8 +5732,8 @@ # name: test_device_registry[ngcubvaqoraolsmtjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5945,7 +5754,6 @@ 'model_id': 'tmsloaroqavbucgn', 'name': 'Pokerlamp 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5954,8 +5762,8 @@ # name: test_device_registry[nnqlg0rxryraf8ezbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5976,7 +5784,6 @@ 'model_id': 'ze8faryrxr0glqnn', 'name': 'Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5985,8 +5792,8 @@ # name: test_device_registry[nr26obpclc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6007,7 +5814,6 @@ 'model_id': 'cpbo62rn', 'name': 'blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6016,8 +5822,8 @@ # name: test_device_registry[nt3mpibadxfqkegldyg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6038,7 +5844,6 @@ 'model_id': 'lgekqfxdabipm3tn', 'name': 'Colorful PIR Night Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6047,8 +5852,8 @@ # name: test_device_registry[nxdcy0uidplnhkazjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6069,7 +5874,6 @@ 'model_id': 'zakhnlpdiu0ycdxn', 'name': 'Stoel', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6078,8 +5882,8 @@ # name: test_device_registry[nyriu7sjgj9oruzmpsm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6100,7 +5904,6 @@ 'model_id': 'mzuro9jgjs7uiryn', 'name': 'Poopy Nano 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6109,8 +5912,8 @@ # name: test_device_registry[o4hpbl5uarjfbzheps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6131,7 +5934,6 @@ 'model_id': 'ehzbfjrau5lbph4o', 'name': 'Dolní vchod - západ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6140,8 +5942,8 @@ # name: test_device_registry[o5kqedcacfng0plpnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6162,7 +5964,6 @@ 'model_id': 'plp0gnfcacdeqk5o', 'name': 'Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6171,8 +5972,8 @@ # name: test_device_registry[o71einxvuuktuljcjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6193,7 +5994,6 @@ 'model_id': 'cjlutkuuvxnie17o', 'name': 'Rauchmelder Alexsandro ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6202,8 +6002,8 @@ # name: test_device_registry[obb7p55c0us6rdxkqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6224,7 +6024,6 @@ 'model_id': 'kxdr6su0c55p7bbo', 'name': 'Metering_3PN_WiFi_stable', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6233,8 +6032,8 @@ # name: test_device_registry[ohefbbk9gcdl] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6255,7 +6054,6 @@ 'model_id': '9kbbfeho', 'name': 'Luminosité', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6264,8 +6062,8 @@ # name: test_device_registry[okwwus27jhqqe2mijbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6286,7 +6084,6 @@ 'model_id': 'im2eqqhj72suwwko', 'name': 'Siren', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6295,8 +6092,8 @@ # name: test_device_registry[ol8xwtcj42eg18bdbrnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6317,7 +6114,6 @@ 'model_id': 'db81ge24jctwx8lo', 'name': 'Hot Water Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6326,8 +6122,8 @@ # name: test_device_registry[oq9ksabjz6tip49tkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6348,7 +6144,6 @@ 'model_id': 't94pit6zjbask9qo', 'name': 'Floor Thermostat Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6357,8 +6152,8 @@ # name: test_device_registry[oqyhsaqwsph] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6379,7 +6174,6 @@ 'model_id': 'wqashyqo', 'name': 'Soil moisture sensor #1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6388,8 +6182,8 @@ # name: test_device_registry[orotles4ucq8rxwn2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6410,7 +6204,6 @@ 'model_id': 'nwxr8qcu4seltoro', 'name': 'X5 Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6419,8 +6212,8 @@ # name: test_device_registry[ouabwhlarnczogyfqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6441,7 +6234,6 @@ 'model_id': 'fygozcnralhwbauo', 'name': 'SPM02_WiFi', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6450,8 +6242,8 @@ # name: test_device_registry[owozxdzgbibizu4sjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6472,7 +6264,6 @@ 'model_id': 's4uzibibgzdxzowo', 'name': 'ION1000PRO', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6481,8 +6272,8 @@ # name: test_device_registry[oxi73pj9a0ubr60pjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6503,7 +6294,6 @@ 'model_id': 'p06rbu0a9jp37ixo', 'name': 'Jardim Casa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6512,8 +6302,8 @@ # name: test_device_registry[p2gnclbiqxrbboagdd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6534,7 +6324,6 @@ 'model_id': 'gaobbrxqiblcng2p', 'name': 'TV Sync Backlights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6543,8 +6332,8 @@ # name: test_device_registry[p5ger7bqlcjtmmqgbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6565,7 +6354,6 @@ 'model_id': 'gqmmtjclqb7reg5p', 'name': 'Wi-Fi Meter(Bi-Directional)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6574,8 +6362,8 @@ # name: test_device_registry[p8xoxccrjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6596,7 +6384,6 @@ 'model_id': 'rccxox8p', 'name': 'Smoke Alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6605,8 +6392,8 @@ # name: test_device_registry[paxijfx9fkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6627,7 +6414,6 @@ 'model_id': '9xfjixap', 'name': 'Empore', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6636,8 +6422,8 @@ # name: test_device_registry[pdasfna8fswh4a0tzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6658,7 +6444,6 @@ 'model_id': 't0a4hwsf8anfsadp', 'name': 'wallwasher front', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6667,8 +6452,8 @@ # name: test_device_registry[pdnimgsb3w0xko3kjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6689,7 +6474,6 @@ 'model_id': 'k3okx0w3bsgmindp', 'name': 'Portal Casa Carro Jalimy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6698,8 +6482,8 @@ # name: test_device_registry[pfhwb1v3i7cifa2tcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6720,7 +6504,6 @@ 'model_id': 't2afic7i3v1bwhfp', 'name': 'Bubbelbad', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6729,8 +6512,8 @@ # name: test_device_registry[ppgdpsq1xaxlyzryjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6751,7 +6534,6 @@ 'model_id': 'yrzylxax1qspdgpp', 'name': 'Bree', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6760,8 +6542,8 @@ # name: test_device_registry[pykascx9yfqrxtbgzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6782,7 +6564,6 @@ 'model_id': 'gbtxrqfy9xcsakyp', 'name': '3DPrinter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6791,8 +6572,8 @@ # name: test_device_registry[pz2xuth8hczv6zrwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6813,7 +6594,6 @@ 'model_id': 'wrz6vzch8htux2zp', 'name': 'Elivco TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6822,8 +6602,8 @@ # name: test_device_registry[q304vac40br8nlkajsywc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6844,7 +6624,6 @@ 'model_id': 'akln8rb04cav403q', 'name': 'Water Fountain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6853,8 +6632,8 @@ # name: test_device_registry[q3iie9vjd4wfqyy1qzkmkc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6875,7 +6654,6 @@ 'model_id': '1yyqfw4djv9eii3q', 'name': 'Garage door ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6884,8 +6662,8 @@ # name: test_device_registry[q62sg0p3s52thp6zzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6906,7 +6684,6 @@ 'model_id': 'z6pht25s3p0gs26q', 'name': '6294HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6915,8 +6692,8 @@ # name: test_device_registry[q8dncqpgin4yympisc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6937,7 +6714,6 @@ 'model_id': 'ipmyy4nigpqcnd8q', 'name': 'Pro Breeze 30L Compressor Dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6946,8 +6722,8 @@ # name: test_device_registry[qe8vvtx4nl21wjd3dytkx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6968,7 +6744,6 @@ 'model_id': '3djw12ln4xtvv8eq', 'name': 'Genio Nebula & Blue Star Projector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6977,8 +6752,8 @@ # name: test_device_registry[qhgghufzqtwloqoqjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6999,7 +6774,6 @@ 'model_id': 'qoqolwtqzfuhgghq', 'name': 'Smart Bulb RGBCW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7008,8 +6782,8 @@ # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7030,7 +6804,6 @@ 'model_id': 'cnpkf4xdmd9v49iq', 'name': '断路器HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7039,8 +6812,8 @@ # name: test_device_registry[qifhbafbqubbp3b6qbnnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7061,7 +6834,6 @@ 'model_id': '6b3pbbuqbfabhfiq', 'name': 'Wi-Fi solar grid micro inverter (GT)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7070,8 +6842,8 @@ # name: test_device_registry[qt0o5jlatiqf2rscps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7092,7 +6864,6 @@ 'model_id': 'csr2fqitalj5o0tq', 'name': 'Intercom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7101,8 +6872,8 @@ # name: test_device_registry[queafegmhhmtivdxjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7123,7 +6894,6 @@ 'model_id': 'xdvitmhhmgefaeuq', 'name': 'druckerhell', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7132,8 +6902,8 @@ # name: test_device_registry[qwExlkou9h2USezrjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7154,7 +6924,6 @@ 'model_id': 'rzeSU2h9uoklxEwq', 'name': 'Inondation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7163,8 +6932,8 @@ # name: test_device_registry[qyy1auihjyoogvb7zdccq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7185,7 +6954,6 @@ 'model_id': '7bvgooyjhiua1yyq', 'name': 'AC charging control box', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7194,8 +6962,8 @@ # name: test_device_registry[r4yrlr705ei31ikmjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7216,7 +6984,6 @@ 'model_id': 'mki13ie507rlry4r', 'name': 'Garage light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7225,8 +6992,8 @@ # name: test_device_registry[rdq0bn4dzuwx2qfujd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7247,7 +7014,6 @@ 'model_id': 'ufq2xwuzd4nb0qdr', 'name': 'Sjiethoes', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7256,8 +7022,8 @@ # name: test_device_registry[ri7eegdifufzdi54dyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7278,7 +7044,6 @@ 'model_id': '45idzfufidgee7ir', 'name': 'Smart White Noise Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7287,8 +7052,8 @@ # name: test_device_registry[rirsc4vhpbv2whkp2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7309,7 +7074,6 @@ 'model_id': 'pkhw2vbphv4csrir', 'name': 'C30', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7318,8 +7082,8 @@ # name: test_device_registry[rl39uwgaqwjwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7340,7 +7104,6 @@ 'model_id': 'agwu93lr', 'name': 'Smart Odor Eliminator-Pro', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7349,8 +7112,8 @@ # name: test_device_registry[rojky4l6yyjreeilnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7371,7 +7134,6 @@ 'model_id': 'lieerjyy6l4ykjor', 'name': 'Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7380,8 +7142,8 @@ # name: test_device_registry[rsjdwgnbqky] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7402,7 +7164,6 @@ 'model_id': 'bngwdjsr', 'name': 'Télécommande lumières ZigBee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7411,8 +7172,8 @@ # name: test_device_registry[rvsneuipzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7433,7 +7194,6 @@ 'model_id': 'piuensvr', 'name': 'Signal repeater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7442,8 +7202,8 @@ # name: test_device_registry[rwp6kdezm97s2nktzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7464,7 +7224,6 @@ 'model_id': 'tkn2s79mzedk6pwr', 'name': 'Weihnachtsmann ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7473,8 +7232,8 @@ # name: test_device_registry[rzt2knqamsxjp8f9ycjjh] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7495,7 +7254,6 @@ 'model_id': '9f8pjxsmaqnk2tzr', 'name': 'MT15/MT29', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7504,8 +7262,8 @@ # name: test_device_registry[s3zzjdcfrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7526,7 +7284,6 @@ 'model_id': 'fcdjzz3s', 'name': 'Motion sensor lidl zigbee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7535,8 +7292,8 @@ # name: test_device_registry[s5ah3novtabe4tfdhb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7557,7 +7314,6 @@ 'model_id': 'dft4ebatvon3ha5s', 'name': 'Smart Kettle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7566,8 +7322,8 @@ # name: test_device_registry[sb3zdertrw50bgogkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7588,7 +7344,6 @@ 'model_id': 'gogb05wrtredz3bs', 'name': 'smart thermostats', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7597,8 +7352,8 @@ # name: test_device_registry[sdq2flqkq0lblcah2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7619,7 +7374,6 @@ 'model_id': 'haclbl0qkqlf2qds', 'name': 'Home Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7628,8 +7382,8 @@ # name: test_device_registry[shga3pmbkwhthvqxgklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7650,7 +7404,6 @@ 'model_id': 'xqvhthwkbmp3aghs', 'name': 'Pergola', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7659,8 +7412,8 @@ # name: test_device_registry[sifg4pfqsylsayg0jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7681,7 +7434,6 @@ 'model_id': '0gyaslysqfp4gfis', 'name': 'Study 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7690,8 +7442,8 @@ # name: test_device_registry[sj55nxhjftilowkejd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7712,7 +7464,6 @@ 'model_id': 'ekwolitfjhxn55js', 'name': 'ab6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7721,8 +7472,8 @@ # name: test_device_registry[slkkzcxa7yjqmetqlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7743,7 +7494,6 @@ 'model_id': 'qtemqjy7axczkkls', 'name': 'Dining 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7752,8 +7502,8 @@ # name: test_device_registry[snbu4b3vekhywztwqgcwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7774,7 +7524,6 @@ 'model_id': 'wtzwyhkev3b4ubns', 'name': 'House Water Level', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7783,8 +7532,8 @@ # name: test_device_registry[sq6fbd3pfkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7805,7 +7554,6 @@ 'model_id': 'p3dbf6qs', 'name': 'Anbau', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7814,8 +7562,8 @@ # name: test_device_registry[srbr1lpaydiq7l5sgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7836,7 +7584,6 @@ 'model_id': 's5l7qidyapl1rbrs', 'name': 'Ventus test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7845,8 +7592,8 @@ # name: test_device_registry[srp7cfjtn6sshwmt2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7867,7 +7614,6 @@ 'model_id': 'tmwhss6ntjfc7prs', 'name': 'Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7876,8 +7622,8 @@ # name: test_device_registry[svjjuwykgijjedurps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7898,7 +7644,6 @@ 'model_id': 'rudejjigkywujjvs', 'name': 'Bürocam', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7907,8 +7652,8 @@ # name: test_device_registry[sw1ejdomlmfubapizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7929,7 +7674,6 @@ 'model_id': 'ipabufmlmodje1ws', 'name': 'Värmelampa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7938,8 +7682,8 @@ # name: test_device_registry[swhtzki3qrz5ydchjboc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7960,7 +7704,6 @@ 'model_id': 'hcdy5zrq3ikzthws', 'name': 'Smogo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7969,8 +7712,8 @@ # name: test_device_registry[sxa4ealyi9cotiugzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7991,7 +7734,6 @@ 'model_id': 'guitoc9iylae4axs', 'name': 'HA Socket Delta Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8000,8 +7742,8 @@ # name: test_device_registry[syep74caderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8022,7 +7764,6 @@ 'model_id': '47peys', 'name': 'Ar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8031,8 +7772,8 @@ # name: test_device_registry[t5zosev6h6wmwyrajbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8053,7 +7794,6 @@ 'model_id': 'arywmw6h6vesoz5t', 'name': 'Rauchmelder Drucker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8062,8 +7802,8 @@ # name: test_device_registry[t7bvnnvplkwhdqm9qtn] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8084,7 +7824,6 @@ 'model_id': '9mqdhwklpvnnvb7t', 'name': 'Бризер Зал', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8093,8 +7832,8 @@ # name: test_device_registry[t88qaeyydamm9xhsddx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8115,7 +7854,6 @@ 'model_id': 'shx9mmadyyeaq88t', 'name': 'Plafond bureau ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8124,8 +7862,8 @@ # name: test_device_registry[tcdk0skzcpisexj2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8146,7 +7884,6 @@ 'model_id': '2jxesipczks0kdct', 'name': 'HVAC Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8155,8 +7892,8 @@ # name: test_device_registry[thdfxdqqlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8177,7 +7914,6 @@ 'model_id': 'qqdxfdht', 'name': 'bedroom blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8186,8 +7922,8 @@ # name: test_device_registry[trffx1ktlyu3tnmljd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8208,7 +7944,6 @@ 'model_id': 'lmnt3uyltk1xffrt', 'name': 'DirectietKamer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8217,8 +7952,8 @@ # name: test_device_registry[tskafaotnfigad6oqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8239,7 +7974,6 @@ 'model_id': 'o6dagifntoafakst', 'name': 'Sprinkler Cesare', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8248,8 +7982,8 @@ # name: test_device_registry[tvgoe1s3fabebcskjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8270,7 +8004,6 @@ 'model_id': 'kscbebaf3s1eogvt', 'name': 'WIFI Smoke alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8279,8 +8012,8 @@ # name: test_device_registry[u8h3bty7qgg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8301,7 +8034,6 @@ 'model_id': '7ytb3h8u', 'name': 'GIEX Watering Timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8310,8 +8042,8 @@ # name: test_device_registry[uBLyTOvlhoRWXKjrps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8332,7 +8064,6 @@ 'model_id': 'rjKXWRohlvOTyLBu', 'name': 'CAM PORCH', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8341,8 +8072,8 @@ # name: test_device_registry[uYmmlWz6zs0dIgYDjbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8363,7 +8094,6 @@ 'model_id': 'DYgId0sz6zWlmmYu', 'name': 'Siren', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8372,8 +8102,8 @@ # name: test_device_registry[uc9fL2NpR79iCzGIzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8394,7 +8124,6 @@ 'model_id': 'IGzCi97RpN2Lf9cu', 'name': 'N4-Auto', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8403,8 +8132,8 @@ # name: test_device_registry[uew54dymycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8425,7 +8154,6 @@ 'model_id': 'myd45weu', 'name': 'Patates', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8434,8 +8162,8 @@ # name: test_device_registry[urm7i0rtdlabqiqygcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8456,7 +8184,6 @@ 'model_id': 'yqiqbaldtr0i7mru', 'name': 'WiFi Temperature & Humidity Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8465,8 +8192,8 @@ # name: test_device_registry[uvh6oeqrfliovfiwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8487,7 +8214,6 @@ 'model_id': 'wifvoilfrqeo6hvu', 'name': 'Licht drucker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8496,8 +8222,8 @@ # name: test_device_registry[vADxMzNytofrgbm4zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8518,7 +8244,6 @@ 'model_id': '4mbgrfotyNzMxDAv', 'name': 'Air Purifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8527,8 +8252,8 @@ # name: test_device_registry[vayhq2aj3p3z6y2ggcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8549,7 +8274,6 @@ 'model_id': 'g2y6z3p3ja2qhyav', 'name': 'NP DownStairs North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8558,8 +8282,8 @@ # name: test_device_registry[vcrfgwvbuybgnj3zqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8580,7 +8304,6 @@ 'model_id': 'z3jngbyubvwgfrcv', 'name': 'Edesanya Energy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8589,8 +8312,8 @@ # name: test_device_registry[ve3ctzrqgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8611,7 +8334,6 @@ 'model_id': 'qrztc3ev', 'name': 'Temperature and humidity sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8620,8 +8342,8 @@ # name: test_device_registry[vnj3sa6mqahro6phjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8642,7 +8364,6 @@ 'model_id': 'hp6orhaqm6as3jnv', 'name': 'Master bedroom TV lights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8651,8 +8372,8 @@ # name: test_device_registry[vpfdskpi8pr8cbtfzjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8673,7 +8394,6 @@ 'model_id': 'ftbc8rp8ipksdfpv', 'name': 'mesa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8682,8 +8402,8 @@ # name: test_device_registry[vrhdtr5fawoiyth9qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8704,7 +8424,6 @@ 'model_id': '9htyiowaf5rtdhrv', 'name': 'Framboisiers', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8713,8 +8432,8 @@ # name: test_device_registry[vx2owjsg86g2ys93zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8735,7 +8454,6 @@ 'model_id': '39sy2g68gsjwo2xv', 'name': 'Ineox SP2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8744,8 +8462,8 @@ # name: test_device_registry[vzu7lkknqjz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8766,7 +8484,6 @@ 'model_id': 'nkkl7uzv', 'name': 'Zigby répéteur ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8775,8 +8492,8 @@ # name: test_device_registry[w8oht6v8aauqa0y8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8797,7 +8514,6 @@ 'model_id': '8y0aquaa8v6tho8w', 'name': 'dressoir spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8806,8 +8522,8 @@ # name: test_device_registry[w9hdtm88xj5crtc1qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8828,7 +8544,6 @@ 'model_id': '1ctrc5jx88mtdh9w', 'name': 'Puerta Casa ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8837,8 +8552,8 @@ # name: test_device_registry[wc6mumew8inrivi9zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8859,7 +8574,6 @@ 'model_id': '9ivirni8wemum6cw', 'name': 'Garáž čerpadlo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8868,8 +8582,8 @@ # name: test_device_registry[weozorgv28n2scribswh] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8890,7 +8604,6 @@ 'model_id': 'ircs2n82vgrozoew', 'name': 'InverFlow', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8899,8 +8612,8 @@ # name: test_device_registry[x4nogasbi8ggpb3lcd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8921,7 +8634,6 @@ 'model_id': 'l3bpgg8ibsagon4x', 'name': 'LSC Party String Light RGBIC+CCT ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8930,8 +8642,8 @@ # name: test_device_registry[x7quooqakw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8952,7 +8664,6 @@ 'model_id': 'aqoouq7x', 'name': 'Clima cucina', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8961,8 +8672,8 @@ # name: test_device_registry[xR2ASpOQgAAqu7Drlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8983,7 +8694,6 @@ 'model_id': 'rD7uqAAgQOpSA2Rx', 'name': 'Kit-Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8992,8 +8702,8 @@ # name: test_device_registry[xenxir4a0tn0p1qcqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9014,7 +8724,6 @@ 'model_id': 'cq1p0nt0a4rixnex', 'name': '4-433', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9023,8 +8732,8 @@ # name: test_device_registry[xihygtyd0d1faknkps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9045,7 +8754,6 @@ 'model_id': 'knkaf1d0dytgyhix', 'name': 'Security Camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9054,8 +8762,8 @@ # name: test_device_registry[xms6qowipdvjnkdgqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9076,7 +8784,6 @@ 'model_id': 'gdknjvdpiwoq6smx', 'name': 'Jardim frontal ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9085,8 +8792,8 @@ # name: test_device_registry[y1dkg3disbacmqfyjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9107,7 +8814,6 @@ 'model_id': 'yfqmcabsid3gkd1y', 'name': 'Shop Light 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9116,8 +8822,8 @@ # name: test_device_registry[y7eeatfzbtbyllk0qbnnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9138,7 +8844,6 @@ 'model_id': '0kllybtbzftaee7y', 'name': 'Soria', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9147,8 +8852,8 @@ # name: test_device_registry[ycttanlnpa0aivbfzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9169,7 +8874,6 @@ 'model_id': 'fbvia0apnlnattcy', 'name': 'AK1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9178,8 +8882,8 @@ # name: test_device_registry[yky6kunazmaitupzjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9200,7 +8904,6 @@ 'model_id': 'zputiamzanuk6yky', 'name': 'Floodlight', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9209,8 +8912,8 @@ # name: test_device_registry[yo2karkjuhzztxsfjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9231,7 +8934,6 @@ 'model_id': 'fsxtzzhujkrak2oy', 'name': 'Kalado Air Purifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9240,8 +8942,8 @@ # name: test_device_registry[yohkwjjdjlzludd3psm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9262,7 +8964,6 @@ 'model_id': '3ddulzljdjjwkhoy', 'name': 'Kattenbak', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9271,8 +8972,8 @@ # name: test_device_registry[yuanswy6scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9293,7 +8994,6 @@ 'model_id': '6ywsnauy', 'name': 'Fenêtre cuisine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9302,8 +9002,8 @@ # name: test_device_registry[yybgnzr3ztws] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9324,7 +9024,6 @@ 'model_id': '3rzngbyy', 'name': 'Grillhőmérő', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9333,8 +9032,8 @@ # name: test_device_registry[z7cu5t8bl9tt9fabjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9355,7 +9054,6 @@ 'model_id': 'baf9tt9lb8t5uc7z', 'name': 'Pokerlamp 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9364,8 +9062,8 @@ # name: test_device_registry[z8woiryqydmzonjdjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9386,7 +9084,6 @@ 'model_id': 'djnozmdyqyriow8z', 'name': 'Fakkel 8', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9395,8 +9092,8 @@ # name: test_device_registry[zaszonjgzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9417,7 +9114,6 @@ 'model_id': 'gjnozsaz', 'name': 'Raspy4 - Home Assistant', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9426,8 +9122,8 @@ # name: test_device_registry[zf8vgiwoa07jwegtjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9448,7 +9144,6 @@ 'model_id': 'tgewj70aowigv8fz', 'name': 'Stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9457,8 +9152,8 @@ # name: test_device_registry[zfHZQ7tZUBxAWjACjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9479,7 +9174,6 @@ 'model_id': 'CAjWAxBUZt7QZHfz', 'name': 'HL400', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9488,8 +9182,8 @@ # name: test_device_registry[zgiyrxflahjowpcckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9510,7 +9204,6 @@ 'model_id': 'ccpwojhalfxryigz', 'name': 'Boiler Temperature Controller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9519,8 +9212,8 @@ # name: test_device_registry[zjh9xhtm3gibs9kizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9541,7 +9234,6 @@ 'model_id': 'ik9sbig3mthx9hjz', 'name': 'Aubess Washing Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9550,8 +9242,8 @@ # name: test_device_registry[zoytcemodrn39zqwrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9572,7 +9264,6 @@ 'model_id': 'wqz93nrdomectyoz', 'name': 'PIR outside stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9581,8 +9272,8 @@ # name: test_device_registry[zrrraytdoanz33rlds] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9603,7 +9294,6 @@ 'model_id': 'lr33znaodtyarrrz', 'name': 'V20', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9612,8 +9302,8 @@ # name: test_device_registry[zspc4q1ut7swycnyzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9634,7 +9324,6 @@ 'model_id': 'yncyws7tu1q4cpsz', 'name': 'Wi-Fi hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9643,8 +9332,8 @@ # name: test_device_registry[zspxfhsvgn2hgtndzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9665,7 +9354,6 @@ 'model_id': 'dntgh2ngvshfxpsz', 'name': 'fakkel veranda ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9674,8 +9362,8 @@ # name: test_device_registry[zuqudhznfzttizpgbrnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9696,7 +9384,6 @@ 'model_id': 'gpzittzfnzhduquz', 'name': 'Inverter Pool Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9705,8 +9392,8 @@ # name: test_device_registry[zwnoax1om13nulplvtderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9727,7 +9414,6 @@ 'model_id': 'lplun31mo1xaonwz', 'name': 'TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9736,8 +9422,8 @@ # name: test_device_registry[zxmrfsffcearbajpjfx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9758,7 +9444,6 @@ 'model_id': 'pjabraecffsfrmxz', 'name': 'Register booster fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9767,8 +9452,8 @@ # name: test_device_registry[zyutbek7wdm1b4cgzckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9789,7 +9474,6 @@ 'model_id': 'gc4b1mdw7kebtuyz', 'name': 'pid_relay_2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9798,8 +9482,8 @@ # name: test_device_registry[zzz87dkfce6pdqxwtk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9820,7 +9504,6 @@ 'model_id': 'wxqdp6ecfkd78zzz', 'name': 'Mini-Split', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 8365f6a79156..b3df44bdac2d 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -84,8 +84,8 @@ # name: test_waste_pickup_calendar.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -106,7 +106,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 8de837cab936..3fbe5efd26c7 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -53,8 +53,8 @@ # name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -135,8 +134,8 @@ # name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -217,8 +215,8 @@ # name: test_sensors[sensor.twente_milieu_organic_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -239,7 +237,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -299,8 +296,8 @@ # name: test_sensors[sensor.twente_milieu_packages_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -321,7 +318,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -381,8 +377,8 @@ # name: test_sensors[sensor.twente_milieu_paper_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -403,7 +399,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/unifiprotect/snapshots/test_init.ambr b/tests/components/unifiprotect/snapshots/test_init.ambr index e53b25c4ad34..3a3e7558a835 100644 --- a/tests/components/unifiprotect/snapshots/test_init.ambr +++ b/tests/components/unifiprotect/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup_creates_nvr_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'UNVR-PRO', 'name': 'UnifiProtect', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '6.0.0', 'via_device_id': None, diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 6a82aa938201..4496af6cc7f2 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -15,7 +15,6 @@ from uiprotect.data import ( Sensor, SmartDetectObjectType, ) -from uiprotect.data.nvr import EventMetadata from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState @@ -51,9 +50,11 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, + setup_public_light, setup_public_sensor, ) @@ -118,6 +119,7 @@ async def test_binary_sensor_setup_light( ) -> None: """Test binary_sensor entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) @@ -729,47 +731,38 @@ async def test_binary_sensor_update_motion( async def test_binary_sensor_update_light_motion( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light, fixed_now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test binary_sensor motion entity.""" + """Test the light motion binary_sensor reads PIR motion from the public API.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) _, entity_id = await ids_from_device_description( hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) + assert hass.states.get(entity_id).state == STATE_OFF - event_metadata = EventMetadata(light_id=light.id) - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.MOTION_LIGHT, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - metadata=event_metadata, - api=ufp.api, - ) - - new_light = light.model_copy() - new_light.is_pir_motion_detected = True - new_light.last_motion_event_id = event.id - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.api.bootstrap.events = {event.id: event} - ufp.ws_msg(mock_msg) + public = make_public_light(light, is_pir_motion_detected=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_ON + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_binary_sensor_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light binary_sensors are unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + for description in LIGHT_SENSOR_WRITE: + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_binary_sensor_update_mount_type_window( diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index ee094d61f422..b224e9ada64d 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -1,9 +1,8 @@ """Test the UniFi Protect light platform.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock -from uiprotect.data import Light -from uiprotect.data.types import LEDLevel +from uiprotect.data import DeviceState, Light from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -12,6 +11,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -22,7 +22,10 @@ from .utils import ( adopt_devices, assert_entity_counts, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) @@ -48,6 +51,7 @@ async def test_light_setup( ) -> None: """Test light entity setup.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -67,21 +71,15 @@ async def test_light_setup( async def test_light_update( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity update.""" + """Test the light reads on/off and brightness from a public WS update.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - new_light = light.model_copy() - new_light.is_light_on = True - new_light.light_device_settings.led_level = LEDLevel(3) - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_light - - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.ws_msg(mock_msg) + # Divergent public values (on, led_level 3 -> 128) prove the read path. + public = make_public_light(light, is_light_on=True, led_level=3) + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() state = hass.states.get("light.test_light") @@ -90,6 +88,56 @@ async def test_light_update( assert state.attributes[ATTR_BRIGHTNESS] == 128 +async def test_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """The light is unavailable without a public object.""" + + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_light_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """Light availability follows the public object's connection state.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + entity_id = "light.test_light" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_light(light, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_light_brightness_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """A light without a public LED level reports no brightness.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + public = make_public_light(light, is_light_on=True) + public.light_device_settings.led_level = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] is None + + async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: @@ -98,6 +146,7 @@ async def test_light_turn_on( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -120,6 +169,7 @@ async def test_light_turn_on_with_brightness( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -146,6 +196,7 @@ async def test_light_turn_off( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index b1b8464d07f4..358413c4d995 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -167,8 +167,9 @@ async def test_number_setup_camera_missing_attr( async def test_number_light_sensitivity( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test sensitivity number entity for lights.""" + """Test sensitivity number entity for lights (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.NUMBER, 2, 2) @@ -180,7 +181,7 @@ async def test_number_light_sensitivity( ) with patch_ufp_method( - light, "set_sensitivity", new_callable=AsyncMock + light, "set_sensitivity_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "number", @@ -192,6 +193,39 @@ async def test_number_light_sensitivity( mock_method.assert_called_once_with(15.0) +async def test_number_light_sensitivity_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Sensitivity reads from the public object and refreshes on a public WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + + # A value the private fixture (45) would not produce proves the public source. + public = make_public_light(light, pir_sensitivity=30) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "30" + + +async def test_number_light_sensitivity_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated sensitivity number is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_number_light_duration( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index d270c1e38242..bb221d448cee 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -42,6 +42,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -56,9 +57,11 @@ from .utils import ( ids_from_device_description, init_entry, make_public_camera, + make_public_light, public_device_ws_message, remove_entities, setup_public_camera, + setup_public_light, ) @@ -113,6 +116,7 @@ async def test_select_setup_light( """Test select entity setup for light devices.""" light.light_mode_settings.enable_at = LightModeEnableType.DARK + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -415,8 +419,9 @@ async def test_select_update_doorbell_message( async def test_select_set_option_light_motion( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test Light Mode select.""" + """Test Light Mode select (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -425,7 +430,7 @@ async def test_select_set_option_light_motion( ) with patch_ufp_method( - light, "set_light_settings", new_callable=AsyncMock + light, "set_light_mode_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "select", @@ -437,6 +442,64 @@ async def test_select_set_option_light_motion( mock_method.assert_called_once_with(LightModeType.MANUAL, enable_at=None) +async def test_select_light_motion_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Light Mode select reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == "motion" + + # The private fixture is full-time motion; when_dark proves the public source. + public = make_public_light( + light, + light_mode=LightModeType.WHEN_DARK, + light_mode_enable_at=LightModeEnableType.DARK, + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "when_dark" + + +async def test_select_light_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light motion select is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_light_motion_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """A light that does not report a public mode leaves the select unknown.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + + public = make_public_light(light) + public.light_mode_settings.mode = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_select_set_option_light_camera( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, camera: Camera ) -> None: diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 499e66ff68c7..ab8e9c9fcc3f 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -11,11 +11,13 @@ from uiprotect.data import ( DeviceState, Event, EventType, + Light, ModelType, Sensor, ) from uiprotect.data.nvr import EventMetadata from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.utils import convert_to_datetime from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -23,6 +25,7 @@ from homeassistant.components.unifiprotect.sensor import ( ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, + LIGHT_SENSORS, MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, @@ -45,10 +48,12 @@ from .utils import ( enable_entity, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, reset_objects, + setup_public_light, setup_public_sensor, time_changed, ) @@ -704,6 +709,13 @@ async def test_aiport_no_sensor_entities( entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + # Check no camera-specific sensors like motion detection exist + for entity in entities: + if entity.domain == Platform.SENSOR: + # Camera-specific sensors should not exist for AI Port + assert "detected_object" not in entity.unique_id + assert "last_motion" not in entity.unique_id + async def test_aiport_no_sensor_entities_on_runtime_adopt( hass: HomeAssistant, @@ -721,3 +733,43 @@ async def test_aiport_no_sensor_entities_on_runtime_adopt( entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + + +async def test_sensor_light_last_motion_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The light's last-motion timestamp reads from the public API.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + # A value the private fixture would not produce proves the public source. + last_motion_ms = 1700000000000 + public = make_public_light(light, last_motion_ms=last_motion_ms) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert ( + hass.states.get(entity_id).state + == convert_to_datetime(last_motion_ms).isoformat() + ) + + +async def test_sensor_light_last_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated last-motion sensor is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 3d2ca313c0cd..a0b3f5a371d8 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -24,7 +24,14 @@ from homeassistant.components.unifiprotect.switch import ( PRIVACY_MODE_SWITCH, ProtectSwitchEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -37,7 +44,10 @@ from .utils import ( enable_entity, ids_from_device_description, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) CAMERA_SWITCHES_BASIC = [ @@ -139,6 +149,7 @@ async def test_switch_setup_light( ) -> None: """Test switch entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -269,6 +280,7 @@ async def test_switch_light_status( ) -> None: """Tests status light switch for lights.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -279,7 +291,7 @@ async def test_switch_light_status( ) with patch_ufp_method( - light, "set_status_light", new_callable=AsyncMock + light, "set_status_light_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -294,6 +306,40 @@ async def test_switch_light_status( mock_method.assert_called_with(False) +async def test_switch_light_status_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Status light switch reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_OFF + + # The private fixture has the indicator disabled; the public ON proves the source. + public = make_public_light(light, is_indicator_enabled=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch_light_status_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated status light switch is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_switch_camera_ssh( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: @@ -569,6 +615,7 @@ async def test_switch_turn_on_client_error( ) -> None: """Test switch turn on with ClientError raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -580,7 +627,7 @@ async def test_switch_turn_on_client_error( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=ClientError("Test error"), ), @@ -596,6 +643,7 @@ async def test_switch_turn_on_not_authorized( ) -> None: """Test switch turn on with NotAuthorized raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -607,7 +655,7 @@ async def test_switch_turn_on_not_authorized( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=NotAuthorized("Not authorized"), ), diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 218f6958dee5..4ae15eef9acf 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -15,6 +15,8 @@ from uiprotect.data import ( Event, EventType, Light, + LightModeEnableType, + LightModeType, ModelType, MountType, ProtectAdoptableDeviceModel, @@ -29,6 +31,7 @@ from uiprotect.data.public_devices import ( PublicHdrMode, PublicLight, PublicLightDeviceSettings, + PublicLightModeSettings, PublicSensor, PublicSensorLeakSettings, PublicSensorMotionSettingsRead, @@ -335,29 +338,65 @@ def make_public_light( light: Light, *, state: DeviceState | None = None, + is_light_on: bool | None = None, + is_dark: bool | None = None, + is_pir_motion_detected: bool | None = None, + last_motion_ms: int | None = None, + led_level: int | None = None, pir_duration_ms: int | None = None, + pir_sensitivity: int | None = None, + is_indicator_enabled: bool | None = None, + light_mode: LightModeType | None = None, + light_mode_enable_at: LightModeEnableType | None = None, ) -> Mock: - """Build a public-API light for the migrated PIR auto-shutoff duration number. + """Build a public-API light mirroring the private fixture's migrated fields. - ``light_device_settings`` mirrors the private fixture (the public API reports - ``pir_duration`` in milliseconds); ``pir_duration_ms`` overrides it so a test - can assert a value the private object would not produce. + Every field the FloodLight entities read over the public API is mirrored from + the private light; each ``*`` override lets a test set a value the private + object would not produce, proving the entity reads the public source. The + public API reports ``pir_duration`` and ``last_motion`` in milliseconds. """ lds = light.light_device_settings + lms = light.light_mode_settings public = Mock(spec=PublicLight) public.id = light.id public.mac = light.mac public.model = ModelType.LIGHT public.state = DeviceState[light.state.name] if state is None else state + public.is_light_on = light.is_light_on if is_light_on is None else is_light_on + public.is_dark = light.is_dark if is_dark is None else is_dark + public.is_pir_motion_detected = ( + light.is_pir_motion_detected + if is_pir_motion_detected is None + else is_pir_motion_detected + ) + if last_motion_ms is not None: + public.last_motion = last_motion_ms + elif light.last_motion is not None: + public.last_motion = round(light.last_motion.timestamp() * 1000) + else: + public.last_motion = None + public.light_mode_settings = PublicLightModeSettings( + mode=lms.mode if light_mode is None else light_mode, + enable_at=( + lms.enable_at if light_mode_enable_at is None else light_mode_enable_at + ), + ) public.light_device_settings = PublicLightDeviceSettings( - is_indicator_enabled=lds.is_indicator_enabled, - led_level=lds.led_level, + is_indicator_enabled=( + lds.is_indicator_enabled + if is_indicator_enabled is None + else is_indicator_enabled + ), + led_level=lds.led_level if led_level is None else led_level, pir_duration=( round(lds.pir_duration.total_seconds() * 1000) if pir_duration_ms is None else pir_duration_ms ), - pir_sensitivity=lds.pir_sensitivity, + pir_sensitivity=( + lds.pir_sensitivity if pir_sensitivity is None else pir_sensitivity + ), ) return public diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 0ac1ec007274..c5b237682700 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -52,8 +52,8 @@ # name: test_uptime_sensor.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -68,7 +68,6 @@ 'model_id': None, 'name': 'Uptime', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 0cfc54fa3a2c..800f64359d70 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -651,19 +651,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, utility_meter_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], ) -> None: - """Test config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed while the source device survives.""" assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() @@ -682,15 +674,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.utility_meter.async_unload_entry", wraps=utility_meter.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -703,8 +692,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d ): assert utility_meter_entity.device_id is None - # Check that the utility_meter config entry is not in the device + # Check that the source device survives and does not contain the utility_meter + # config entry sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed @@ -962,7 +953,7 @@ async def test_migration_2_1( tariffs: list[str], expected_entities: set[str], ) -> None: - """Test migration from v2.1 removes utility_meter config entry from device.""" + """Test migration from v2.1 does not add the utility_meter config entry to the device.""" utility_meter_config_entry = MockConfigEntry( data={}, @@ -983,25 +974,15 @@ async def test_migration_2_1( ) utility_meter_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() assert utility_meter_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entities are linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Check that the entities are linked to the other device entities = set() for ( utility_meter_entity diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 847366bf21a3..f70c73280ee9 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,7 +22,7 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"VoltageInstallation":230,"ChargeEnergy":1.8,"ChargeMode":1,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 0383abc0313b..96165c662ff8 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': '99', 'name': 'Bedroom kid 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': '8', 'name': 'Input', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': '123', 'name': 'Kitchen (VMB2BLE)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '2.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -112,15 +109,14 @@ 'model_id': '9', 'name': 'Dimmer full name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6g7', 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -141,15 +137,14 @@ 'model_id': '10', 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12345', 'sw_version': '1.0.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -170,15 +165,14 @@ 'model_id': '4', 'name': 'Input', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -199,15 +193,14 @@ 'model_id': '1', 'name': 'Living room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'asdfghjk', 'sw_version': '3.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -228,15 +221,14 @@ 'model_id': '3', 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'qwerty1234567', 'sw_version': '1.1.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -257,15 +249,14 @@ 'model_id': '2', 'name': 'Living room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'qwerty123', 'sw_version': '1.0.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -286,7 +277,6 @@ 'model_id': '10', 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '1.0.1', 'via_device_id': , diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr index be23b3698d64..872d2b3536c1 100644 --- a/tests/components/vesync/snapshots/test_binary_sensor.ambr +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -262,8 +255,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -284,7 +277,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -327,8 +319,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -349,7 +341,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -462,8 +453,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -484,7 +475,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -597,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -619,7 +609,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -704,8 +693,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -726,7 +715,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -741,8 +729,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -763,7 +751,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -778,8 +765,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -800,7 +787,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -815,8 +801,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -837,7 +823,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -852,8 +837,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -874,7 +859,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 75a1ac7b7f9d..c3b59abbe3de 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -104,8 +103,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -126,7 +125,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -205,8 +203,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -227,7 +225,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -308,8 +305,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -330,7 +327,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -411,8 +407,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -433,7 +429,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -448,8 +443,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -470,7 +465,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -485,8 +479,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -507,7 +501,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -522,8 +515,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -544,7 +537,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -559,8 +551,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -581,7 +573,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -596,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -618,7 +609,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -633,8 +623,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -655,7 +645,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -670,8 +659,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -692,7 +681,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -707,8 +695,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -729,7 +717,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -813,8 +800,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -835,7 +822,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -850,8 +836,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -872,7 +858,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -887,8 +872,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -909,7 +894,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_humidifier.ambr b/tests/components/vesync/snapshots/test_humidifier.ambr index 6af8093874bd..c258a9e8538d 100644 --- a/tests/components/vesync/snapshots/test_humidifier.ambr +++ b/tests/components/vesync/snapshots/test_humidifier.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -262,8 +255,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -284,7 +277,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -299,8 +291,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -321,7 +313,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -401,8 +392,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -423,7 +414,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -507,8 +497,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -529,7 +519,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -611,8 +600,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -633,7 +622,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -648,8 +636,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -670,7 +658,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -685,8 +672,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -707,7 +694,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -722,8 +708,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -744,7 +730,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -759,8 +744,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -781,7 +766,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index a3ac56d74ad8..bc15410d43ce 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -320,8 +313,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -342,7 +335,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -415,8 +407,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -437,7 +429,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -452,8 +443,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -474,7 +465,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -489,8 +479,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -511,7 +501,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -526,8 +515,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -548,7 +537,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -563,8 +551,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -585,7 +573,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -600,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -622,7 +609,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -713,8 +699,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -735,7 +721,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -750,8 +735,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -772,7 +757,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index d5d334e80f66..b7a4971431b9 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -140,8 +139,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -162,7 +161,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -229,8 +227,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -251,7 +249,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -419,8 +416,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -441,7 +438,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -609,8 +605,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -631,7 +627,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -934,8 +929,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -956,7 +951,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1251,8 +1245,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1273,7 +1267,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1288,8 +1281,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1310,7 +1303,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1325,8 +1317,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1347,7 +1339,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1415,8 +1406,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1437,7 +1428,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1613,8 +1603,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1635,7 +1625,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1703,8 +1692,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1725,7 +1714,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2076,8 +2064,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2098,7 +2086,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2113,8 +2100,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2135,7 +2122,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2150,8 +2136,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2172,7 +2158,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2187,8 +2172,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2209,7 +2194,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index a1458f6a8a36..b3f0eba8c265 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -88,8 +87,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -110,7 +109,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -221,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -243,7 +241,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -354,8 +351,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -376,7 +373,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -487,8 +483,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -509,7 +505,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -524,8 +519,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -546,7 +541,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -561,8 +555,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -583,7 +577,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -598,8 +591,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -620,7 +613,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -635,8 +627,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -657,7 +649,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -768,8 +759,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -790,7 +781,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -997,8 +987,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1019,7 +1009,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1130,8 +1119,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1152,7 +1141,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1216,8 +1204,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1238,7 +1226,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1301,8 +1288,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1323,7 +1310,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1338,8 +1324,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1360,7 +1346,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1424,8 +1409,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1446,7 +1431,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_update.ambr b/tests/components/vesync/snapshots/test_update.ambr index e45b489754aa..04d7ff10d744 100644 --- a/tests/components/vesync/snapshots/test_update.ambr +++ b/tests/components/vesync/snapshots/test_update.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -101,8 +100,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -123,7 +122,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -199,8 +197,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -221,7 +219,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -297,8 +294,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -319,7 +316,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -395,8 +391,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -417,7 +413,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -493,8 +488,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -515,7 +510,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +585,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +607,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -689,8 +682,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -711,7 +704,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -787,8 +779,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -809,7 +801,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -885,8 +876,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -907,7 +898,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -983,8 +973,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1005,7 +995,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1081,8 +1070,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1103,7 +1092,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1179,8 +1167,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1201,7 +1189,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1277,8 +1264,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1299,7 +1286,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1375,8 +1361,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1397,7 +1383,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1474,8 +1459,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1496,7 +1481,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vibration/__init__.py b/tests/components/vibration/__init__.py new file mode 100644 index 000000000000..7bb5d6ed877e --- /dev/null +++ b/tests/components/vibration/__init__.py @@ -0,0 +1 @@ +"""Tests for the vibration integration.""" diff --git a/tests/components/vibration/test_condition.py b/tests/components/vibration/test_condition.py new file mode 100644 index 000000000000..a81280df8147 --- /dev/null +++ b/tests/components/vibration/test_condition.py @@ -0,0 +1,196 @@ +"""Test vibration conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.is_detected", {}, True, True), + ("vibration.is_not_detected", {}, True, True), + ], +) +async def test_vibration_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_any( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_all( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ( + "condition_key", + "state_matching", + "state_non_matching", + ), + [ + ( + "vibration.is_detected", + STATE_ON, + STATE_OFF, + ), + ( + "vibration.is_not_detected", + STATE_OFF, + STATE_ON, + ), + ], +) +async def test_vibration_condition_excludes_non_vibration_device_class( + hass: HomeAssistant, + condition_key: str, + state_matching: str, + state_non_matching: str, +) -> None: + """Test vibration condition excludes entities without device_class vibration.""" + entity_id_vibration = "binary_sensor.test_vibration" + entity_id_motion = "binary_sensor.test_motion" + + hass.states.async_set( + entity_id_vibration, state_matching, {ATTR_DEVICE_CLASS: "vibration"} + ) + hass.states.async_set( + entity_id_motion, + state_matching, + {ATTR_DEVICE_CLASS: "motion"}, + ) + await hass.async_block_till_done() + + condition_any = await create_target_condition( + hass, + condition=condition_key, + target={CONF_ENTITY_ID: [entity_id_vibration, entity_id_motion]}, + behavior="any", + ) + + assert condition_any.async_check() is True + + hass.states.async_set( + entity_id_vibration, + state_non_matching, + {ATTR_DEVICE_CLASS: "vibration"}, + ) + await hass.async_block_till_done() + + assert condition_any.async_check() is False diff --git a/tests/components/vibration/test_trigger.py b/tests/components/vibration/test_trigger.py new file mode 100644 index 000000000000..98e355952dec --- /dev/null +++ b/tests/components/vibration/test_trigger.py @@ -0,0 +1,200 @@ +"""Test vibration trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_all, + assert_trigger_behavior_each, + assert_trigger_behavior_first, + assert_trigger_options_supported, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.detected", {}, True, True), + ("vibration.cleared", {}, True, True), + ], +) +async def test_vibration_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_each( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires for binary_sensor entities with device_class vibration.""" + await assert_trigger_behavior_each( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_first( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires on the first binary_sensor state change.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires when all binary_sensors have changed state.""" + await assert_trigger_behavior_all( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 90dc29f25533..4cb265422f3a 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -38,33 +38,37 @@ class MockPyViCare: """Init a single device from json dump.""" self.devices = [] for idx, fixture in enumerate(fixtures): + service = MockViCareService( + f"installation{idx}", f"gateway{idx}", f"deviceId{idx}", fixture + ) self.devices.append( PyViCareDeviceConfig( - MockViCareService( - f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture - ), - f"deviceId{idx}", + service.accessor, + service, "Vitovalor" if fixture.data_file.endswith("VitoValor.json") else f"model{idx}", "Online", + roles=list(fixture.roles), ) ) # Simulate a device with an unsupported deviceType that PyViCare's # `devices` filter would drop but should still appear in `all_devices` # (used by diagnostics). + unsupported_service = MockViCareService( + "installation_unsupported", + "gateway_unsupported", + "deviceId_unsupported", + Fixture(set(), "vicare/dummy-device-no-serial.json"), + ) self.all_devices = [ *self.devices, PyViCareDeviceConfig( - MockViCareService( - "installation_unsupported", - "gateway_unsupported", - "device_unsupported", - Fixture(set(), "vicare/dummy-device-no-serial.json"), - ), - "deviceId_unsupported", + unsupported_service.accessor, + unsupported_service, "unsupported_model", "Online", + roles=[], ), ] @@ -88,6 +92,7 @@ class MockViCareService: """Initialize the mock from a json dump.""" self._test_data = load_json_object_fixture(fixture.data_file) self.fetch_all_features = Mock(return_value=self._test_data) + self.setProperty = Mock() self.roles = fixture.roles self.accessor = ViCareDeviceAccessor(installation_id, gateway_id, device_id) @@ -95,7 +100,7 @@ class MockViCareService: """Return true if requested roles are assigned.""" return requested_roles and set(requested_roles).issubset(self.roles) - def getProperty(self, property_name: str): + def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str): """Read a property from json dump.""" return readFeature(self._test_data["data"], property_name) diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index fb27ef68d284..4f189f5bc56b 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4715,6 +4715,7 @@ 'id': 'deviceId0', 'modelId': 'model0', 'roles': list([ + 'type:boiler', ]), 'status': 'Online', 'type': None, diff --git a/tests/components/vilfo/snapshots/test_init.ambr b/tests/components/vilfo/snapshots/test_init.ambr index 1c33ab98a2c1..500139c8de47 100644 --- a/tests/components/vilfo/snapshots/test_init.ambr +++ b/tests/components/vilfo/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry[with_mac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': None, 'name': 'Vilfo Router', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1.0', 'via_device_id': None, @@ -38,8 +37,8 @@ # name: test_device_registry[without_mac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -61,7 +60,6 @@ 'model_id': None, 'name': 'Vilfo Router', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1.0', 'via_device_id': None, diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index bfb260a1e782..c2a8bd2a619d 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -26,7 +26,6 @@ from .const import ( RESPONSE_TOKEN, UNIQUE_ID, VERSION, - ZEROCONF_HOST, MockCompletePairingResponse, MockStartPairingResponse, ) @@ -336,13 +335,3 @@ def vizio_update_with_apps_on_input_fixture(vizio_update: None) -> Generator[Non ), ): yield - - -@pytest.fixture(name="vizio_hostname_check") -def vizio_hostname_check() -> Generator[None]: - """Mock vizio hostname resolution.""" - with patch( - "homeassistant.components.vizio.config_flow.socket.gethostbyname", - return_value=ZEROCONF_HOST, - ): - yield diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 933647ca7c54..2201aeb5d6e9 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -445,6 +445,36 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: # Flow should abort because device is already setup assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == HOST + + +@pytest.mark.usefixtures( + "vizio_connect", "vizio_bypass_setup", "vizio_guess_device_type" +) +async def test_zeroconf_flow_already_configured_updates_host( + hass: HomeAssistant, +) -> None: + """Test zeroconf discovery updates the stored host when the IP has changed.""" + config = MOCK_SPEAKER_CONFIG.copy() + config[CONF_HOST] = HOST2 + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + options={CONF_VOLUME_STEP: VOLUME_STEP}, + unique_id=UNIQUE_ID, + ) + entry.add_to_hass(hass) + + # Rediscover the same device on a new IP address + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info + ) + + # Flow should abort and the entry host should be updated to the new IP + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == HOST @pytest.mark.usefixtures( @@ -555,10 +585,7 @@ async def test_zeroconf_abort_when_ignored(hass: HomeAssistant) -> None: @pytest.mark.usefixtures( - "vizio_connect", - "vizio_bypass_setup", - "vizio_hostname_check", - "vizio_guess_device_type", + "vizio_connect", "vizio_bypass_setup", "vizio_guess_device_type" ) async def test_zeroconf_flow_already_configured_hostname(hass: HomeAssistant) -> None: """Test already configured during zeroconf when entry uses hostname.""" @@ -578,6 +605,8 @@ async def test_zeroconf_flow_already_configured_hostname(hass: HomeAssistant) -> DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) - # Flow should abort because device is already setup + # Flow should abort because device is already setup and the hostname should + # be replaced with the discovered IP assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == HOST diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py index a5c85b57e7a4..7cb642aa9b75 100644 --- a/tests/components/waqi/test_init.py +++ b/tests/components/waqi/test_init.py @@ -201,6 +201,10 @@ async def test_migration_from_v1( "sensor_entity_id": ( "sensor.not_de_jongweg_utrecht_air_quality_index" ), + # Device 2 was created enabled; the migration moves it onto the + # disabled merged config entry, so the move re-evaluates it as disabled + # by CONFIG_ENTRY (the entity keeps its own disabled_by - propagating a + # move-disable to entities is a separate mechanism) "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, "entity_disabled_by": None, "device": 1, diff --git a/tests/components/wattwaechter/test_config_flow.py b/tests/components/wattwaechter/test_config_flow.py index 8321fbe689c8..867a741641ef 100644 --- a/tests/components/wattwaechter/test_config_flow.py +++ b/tests/components/wattwaechter/test_config_flow.py @@ -479,3 +479,104 @@ async def test_reauth_flow_wrong_device( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "wrong_device" assert mock_config_entry.data[CONF_TOKEN] == MOCK_TOKEN + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring the host updates the entry and keeps the token.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + new_host = "192.168.1.222" + # The token field is pre-filled with the stored token and submitted as-is + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: new_host, CONF_TOKEN: MOCK_TOKEN} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == new_host + assert mock_config_entry.data[CONF_TOKEN] == MOCK_TOKEN + + +async def test_reconfigure_flow_clear_token( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test clearing the token field stores None instead of an empty string.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST, CONF_TOKEN: ""} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_TOKEN] is None + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (WattwaechterAuthenticationError("Invalid token"), "invalid_auth"), + (WattwaechterConnectionError("Connection lost"), "cannot_connect"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reconfigure recovers after an invalid token or connection failure.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "reconfigure" + + mock_client.system_info.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"]["base"] == expected_error + + # Retry succeeds once the device is reachable again + mock_client.system_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_wrong_device( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure aborts when the host points to a different device.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "reconfigure" + + mock_client.system_info.return_value = MagicMock( + **{"get_value.return_value": "WRONG-DEVICE"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.222"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + assert mock_config_entry.data[CONF_HOST] == MOCK_HOST diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 75a97e2fd54b..b1b172d23bbb 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -38,8 +38,8 @@ # name: test_entity_attributes.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -60,7 +60,6 @@ 'model_id': None, 'name': 'LG webOS TV MODEL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'major.minor', 'via_device_id': None, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c87f83a0f273..2fecb7157b0f 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -127,15 +127,30 @@ async def target_entities( area_registry.async_update(label_area.id, labels={label1.label_id}) - device1 = dr.DeviceEntry(id="device1", identifiers={("test", "device1")}) - device2 = dr.DeviceEntry(id="device2", identifiers={("test", "device2")}) + device1 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device1", + identifiers={("test", "device1")}, + ) + device2 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device2", + identifiers={("test", "device2")}, + ) area_device = dr.DeviceEntry( - id="area_device", identifiers={("test", "device3")}, area_id=kitchen_area.id + config_entry_id=config_entry.entry_id, + id="area_device", + identifiers={("test", "device3")}, + area_id=kitchen_area.id, ) label2_device = dr.DeviceEntry( - id="label_device", identifiers={("test", "device4")}, labels={label2.label_id} + config_entry_id=config_entry.entry_id, + id="label_device", + identifiers={("test", "device4")}, + labels={label2.label_id}, ) diag_only_device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, id="diag_only_device", identifiers={("test", "device5")}, area_id=garage_area.id, diff --git a/tests/components/whirlpool/snapshots/test_number.ambr b/tests/components/whirlpool/snapshots/test_number.ambr new file mode 100644 index 000000000000..841aa241e2b1 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_number.ambr @@ -0,0 +1,184 @@ +# serializer version: 1 +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lower oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_lower', + 'unique_id': 'said_oven_dual-target_temperature_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Lower oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upper oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_upper', + 'unique_id': 'said_oven_dual-target_temperature_upper', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Upper oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature', + 'unique_id': 'said_oven_single-target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Single cavity oven Target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_select.ambr b/tests/components/whirlpool/snapshots/test_select.ambr index 3371180eadcb..191c9a371d30 100644 --- a/tests/components/whirlpool/snapshots/test_select.ambr +++ b/tests/components/whirlpool/snapshots/test_select.ambr @@ -65,3 +65,216 @@ 'state': '0', }) # --- +# name: test_all_entities[select.dual_cavity_oven_lower_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dual_cavity_oven_lower_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower oven cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode_lower', + 'unique_id': 'said_oven_dual-cook_mode_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_lower_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Dual cavity oven Lower oven cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.dual_cavity_oven_lower_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_upper_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dual_cavity_oven_upper_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper oven cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode_upper', + 'unique_id': 'said_oven_dual-cook_mode_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_upper_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Dual cavity oven Upper oven cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.dual_cavity_oven_upper_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[select.single_cavity_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.single_cavity_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode', + 'unique_id': 'said_oven_single-cook_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.single_cavity_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Single cavity oven Cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.single_cavity_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index f4ec93c81fe0..ad2625d64019 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -149,78 +149,6 @@ 'state': 'running_maincycle', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode_lower', - 'unique_id': 'said_oven_dual-oven_cook_mode_lower', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Dual cavity oven Lower oven cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_lower_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -341,136 +269,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_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.dual_cavity_oven_lower_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_lower', - 'unique_id': 'said_oven_dual-oven_target_temperature_lower', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Lower oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode_upper', - 'unique_id': 'said_oven_dual-oven_cook_mode_upper', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Dual cavity oven Upper oven cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_upper_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -591,136 +389,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_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.dual_cavity_oven_upper_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_upper', - 'unique_id': 'said_oven_dual-oven_target_temperature_upper', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Upper oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.single_cavity_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode', - 'unique_id': 'said_oven_single-oven_cook_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Single cavity oven Cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.single_cavity_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -841,64 +509,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.single_cavity_oven_target_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.single_cavity_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature', - 'unique_id': 'said_oven_single-oven_target_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Single cavity oven Target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.washer_detergent_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/whirlpool/test_number.py b/tests/components/whirlpool/test_number.py new file mode 100644 index 000000000000..b2db959dfc34 --- /dev/null +++ b/tests/components/whirlpool/test_number.py @@ -0,0 +1,177 @@ +"""Test the Whirlpool number platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import whirlpool + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +@pytest.fixture( + params=[ + ( + "number.single_cavity_oven_target_temperature", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_upper_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_lower_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_number_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven target-temperature number entities.""" + return request.param + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +async def test_target_temperature_value( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading and updating the target temperature.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "200" + + mock.get_target_temp.return_value = 220 + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "220" + + +async def test_set_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test setting the target temperature issues a cook command.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_fractional_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a fractional target temperature is passed through without truncation.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220.5}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220.5, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_target_temperature_failure( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a failed request raises HomeAssistantError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.return_value = False + await init_integration(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +async def test_set_target_temperature_value_error( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the temperature raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +@pytest.mark.parametrize("current_mode", [whirlpool.oven.CookMode.Standby, None]) +async def test_set_target_temperature_from_idle( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + current_mode: whirlpool.oven.CookMode | None, +) -> None: + """Test that setting the temperature with no active cook defaults to Bake.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = current_mode + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Bake, cavity=cavity + ) diff --git a/tests/components/whirlpool/test_select.py b/tests/components/whirlpool/test_select.py index 665b0cb44bf0..fc56c59792c8 100644 --- a/tests/components/whirlpool/test_select.py +++ b/tests/components/whirlpool/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion +import whirlpool from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform @@ -94,3 +95,128 @@ async def test_select_option_value_error( }, blocking=True, ) + + +@pytest.fixture( + params=[ + ( + "select.single_cavity_oven_cook_mode", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "select.dual_cavity_oven_upper_oven_cook_mode", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "select.dual_cavity_oven_lower_oven_cook_mode", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_cook_mode_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven cook-mode select entities.""" + return request.param + + +async def test_oven_cook_mode_current( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading the current cook mode.""" + entity_id, mock_fixture, _ = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "bake" + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "broil" + + +async def test_oven_cook_mode_select( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting a cook mode issues a cook command.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=200, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_oven_cook_mode_select_from_idle( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting a mode with no target set uses the default temperature.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_target_temp.return_value = None + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=175, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_oven_cook_mode_select_standby( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting standby stops the cook instead of starting one.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "standby"}, + blocking=True, + ) + mock.stop_cook.assert_called_once_with(cavity) + mock.set_cook.assert_not_called() + + +async def test_oven_cook_mode_select_value_error( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the cook mode raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 578232f4641b..38cbb4eea01d 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -6,13 +6,16 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from whirlpool.dryer import MachineState as DryerMachineState -from whirlpool.oven import CavityState as OvenCavityState, CookMode +from whirlpool.oven import CavityState as OvenCavityState from whirlpool.washer import MachineState as WasherMachineState +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.whirlpool.const import DOMAIN from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -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 homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback @@ -324,22 +327,6 @@ async def test_washer_running_states( (None, STATE_UNKNOWN), ], ), - ( - "sensor.dual_cavity_oven_upper_oven_cook_mode", - "mock_oven_dual_cavity_api", - "get_cook_mode", - [ - (CookMode.Standby, "standby"), - (CookMode.Bake, "bake"), - (CookMode.ConvectBake, "convection_bake"), - (CookMode.Broil, "broil"), - (CookMode.ConvectBroil, "convection_broil"), - (CookMode.ConvectRoast, "convection_roast"), - (CookMode.KeepWarm, "keep_warm"), - (CookMode.AirFry, "air_fry"), - (None, STATE_UNKNOWN), - ], - ), ( "sensor.single_cavity_oven_state", "mock_oven_single_cavity_api", @@ -351,22 +338,6 @@ async def test_washer_running_states( (None, STATE_UNKNOWN), ], ), - ( - "sensor.single_cavity_oven_cook_mode", - "mock_oven_single_cavity_api", - "get_cook_mode", - [ - (CookMode.Standby, "standby"), - (CookMode.Bake, "bake"), - (CookMode.ConvectBake, "convection_bake"), - (CookMode.Broil, "broil"), - (CookMode.ConvectBroil, "convection_broil"), - (CookMode.ConvectRoast, "convection_roast"), - (CookMode.KeepWarm, "keep_warm"), - (CookMode.AirFry, "air_fry"), - (None, STATE_UNKNOWN), - ], - ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -390,3 +361,227 @@ async def test_simple_enum_sensors( state = hass.states.get(entity_id) assert state is not None assert state.state == expected_state + + +# The oven cook mode sensor has been replaced by a select entity and is deprecated. +DEPRECATED_COOK_MODE_UNIQUE_ID = "said_oven_single-oven_cook_mode" +DEPRECATED_COOK_MODE_ISSUE_ID = "deprecated_oven_cook_mode_said_oven_single" + + +async def test_oven_cook_mode_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated cook mode sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_cook_mode") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_cook_mode_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing cook mode sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_cook_mode") + assert state is not None + assert state.state == "bake" + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) in issue_registry.issues + + +async def test_oven_cook_mode_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated cook mode sensor is removed and the issue cleared.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_cook_mode_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled cook mode sensor used by an automation is kept and flagged.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_cook_mode", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_cook_mode_scripts" + + +# The oven target temperature sensor has been replaced by a number entity. +DEPRECATED_TARGET_TEMP_UNIQUE_ID = "said_oven_single-oven_target_temperature" +DEPRECATED_TARGET_TEMP_ISSUE_ID = "deprecated_oven_target_temperature_said_oven_single" + + +async def test_oven_target_temperature_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated target temperature sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_target_temperature") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing target temperature sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_target_temperature") + assert state is not None + assert state.state == "200" + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) in issue_registry.issues + + +async def test_oven_target_temperature_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated target temperature sensor is removed.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled target temperature sensor used by an automation is kept.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_target_temperature", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_target_temperature_scripts" diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 885a32234dd1..1d24b827924e 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -52,8 +52,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_admin].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -74,7 +74,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -134,8 +133,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_created].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -156,7 +155,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -220,8 +218,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_days_until_expiration].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -242,7 +240,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -302,8 +299,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_expires].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -324,7 +321,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -384,8 +380,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_last_updated].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -406,7 +402,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -465,8 +460,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_owner].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -487,7 +482,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -546,8 +540,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_registrant].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -568,7 +562,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -627,8 +620,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_registrar].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -649,7 +642,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -708,8 +700,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_reseller].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -730,7 +722,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -841,8 +832,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_status].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -863,7 +854,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index 31c239876803..21d04fd18ba4 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[12345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'henk', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Body+', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index c07f001c8e3a..0a44756f8f4a 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -449,3 +449,58 @@ async def test_device_two_config_entries( await hass.async_block_till_done() assert "Platform withings does not generate unique IDs" not in caplog.text + + +async def test_old_device_removal_only_removes_own_device( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + second_polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Removing an old device only removes the processing entry's own device. + + Two config entries can each own a device registry entry for the same shared sub-device. + When the sub-device disappears from one entry, it must remove its own device, not + another entry's device sharing the identifier. + """ + identifiers = {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")} + + def _device_for_entry(entry: MockConfigEntry) -> dr.DeviceEntry | None: + return next( + ( + device + for device in device_registry.devices.get_entries( + identifiers=identifiers + ) + if device.config_entry_id == entry.entry_id + ), + None, + ) + + # The first entry creates the sub-device and owns its device registry entry. + await setup_integration(hass, polling_config_entry, False) + assert _device_for_entry(polling_config_entry) is not None + + # Unload it, then set up a second entry: with the first entry unloaded it no longer + # provides the sub-device, so the second entry creates and owns its own device. + await hass.config_entries.async_unload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + second_polling_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(second_polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert _device_for_entry(polling_config_entry) is not None + assert _device_for_entry(second_polling_config_entry) is not None + + # The sub-device disappears from the (still loaded) second entry's data. + withings.get_devices.return_value = [] + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only the second entry's own device was removed; the first entry's remains. + assert _device_for_entry(second_polling_config_entry) is None + assert _device_for_entry(polling_config_entry) is not None diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index a833087b8082..043044fa868e 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -2,8 +2,8 @@ # name: test_device_snapshot DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 51225d300839..a9b44bc1cf41 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -61,8 +61,8 @@ # name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -87,7 +87,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, @@ -155,8 +154,8 @@ # name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -181,7 +180,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 26bf8feed48d..9f47e6213ec3 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -2,8 +2,8 @@ # name: test_cover_device[config_prod_awning_dimmer.json-status_prod_awning.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr index 34b55c0ac3e1..b62cd611dc5a 100644 --- a/tests/components/wmspro/snapshots/test_init.ambr +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-19239] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '19239', 'sw_version': None, 'via_device_id': , @@ -33,8 +32,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-58717] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , @@ -64,8 +62,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-97358] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-19239] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '19239', 'sw_version': None, 'via_device_id': , @@ -126,8 +122,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-58717] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , @@ -157,8 +152,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-97358] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , @@ -188,8 +182,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-116682] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': None, 'name': 'Wohnzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '116682', 'sw_version': None, 'via_device_id': , @@ -219,8 +212,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-172555] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': None, 'name': 'Badezimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '172555', 'sw_version': None, 'via_device_id': , @@ -250,8 +242,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-18894] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': None, 'name': 'Wohnebene alle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '18894', 'sw_version': None, 'via_device_id': , @@ -281,8 +272,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-230952] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': None, 'name': 'Sportzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '230952', 'sw_version': None, 'via_device_id': , @@ -312,8 +302,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-284942] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '284942', 'sw_version': None, 'via_device_id': , @@ -343,8 +332,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-328518] DeviceRegistryEntrySnapshot({ 'area_id': 'alle', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': None, 'name': 'alle Rollläden', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '328518', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index 3fb4ac620efe..aa556fd9c42a 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -2,8 +2,8 @@ # name: test_light_device[config_prod_awning_dimmer.json-status_prod_dimmer.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index 95dfd47a905d..87b1e4fe59e1 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -16,8 +16,8 @@ # name: test_scene_room_device[config_test.json] DeviceRegistryEntrySnapshot({ 'area_id': 'raum_0', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -38,7 +38,6 @@ 'model_id': None, 'name': 'Raum 0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '42581', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_switch.ambr b/tests/components/wmspro/snapshots/test_switch.ambr index b5fcc25c5ea3..144ec10ea724 100644 --- a/tests/components/wmspro/snapshots/test_switch.ambr +++ b/tests/components/wmspro/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_switch_device[config_prod_load_switch.json-status_prod_load_switch.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'HEIZUNG LINKS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '499120', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index 88ce626f4aa2..994672355869 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.wolf-smartset.com/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'test-device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index 445411eb6fc7..7b10a4e8989b 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -11,7 +11,7 @@ from wolf_comm.token_auth import InvalidAuth from wolf_comm.wolf_client import FetchFailed, ParameterReadError from homeassistant.components.wolflink.const import DOMAIN, MANUFACTURER -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -233,8 +233,9 @@ async def test_migration_merges_duplicate_v1_entries( wolf_mock.return_value.fetch_system_list.side_effect = RequestError( "Unable to connect" ) + # Setting up the first entry loads the integration, which sets up and migrates + # every wolflink entry: the first becomes the hub and the second merges into it. await hass.config_entries.async_setup(first_entry.entry_id) - await second_entry.async_migrate(hass) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) @@ -251,6 +252,58 @@ async def test_migration_merges_duplicate_v1_entries( assert device.config_entries == {surviving.entry_id} +async def test_migration_merge_into_disabled_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an enabled device merged onto a disabled hub entry gets disabled.""" + hub_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + version=2, + minor_version=2, + disabled_by=ConfigEntryDisabler.USER, + ) + hub_entry.add_to_hass(hass) + legacy_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5678", + data={**LEGACY_CONFIG, "device_id": 5678}, + version=1, + minor_version=2, + ) + legacy_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=legacy_entry.entry_id, + identifiers={(DOMAIN, "5678")}, + manufacturer=MANUFACTURER, + name="test-device", + ) + + with patch( + "homeassistant.components.wolflink.WolfClient", + autospec=True, + ) as wolf_mock: + wolf_mock.return_value.fetch_system_list.side_effect = RequestError( + "Unable to connect" + ) + await hass.config_entries.async_setup(legacy_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].entry_id == hub_entry.entry_id + + # The device was reattached to the disabled hub entry, and its disabled + # state now reflects the new owning entry's disabled state. + migrated_device = device_registry.async_get(device.id) + assert migrated_device is not None + assert migrated_device.config_entries == {hub_entry.entry_id} + assert migrated_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + async def test_migration_v1_list_device_id(hass: HomeAssistant) -> None: """Test v1 migration tolerates device_id stored as a list from partial migrations.""" config_entry = MockConfigEntry( diff --git a/tests/components/xthings_cloud/snapshots/test_init.ambr b/tests/components/xthings_cloud/snapshots/test_init.ambr index d0c552b66cd4..c1e7dda19e1f 100644 --- a/tests/components/xthings_cloud/snapshots/test_init.ambr +++ b/tests/components/xthings_cloud/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[XT-LT050] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Porch Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[XT-LT100] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Hallway Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[XT-LT200] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Bedroom Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.1', 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_devices[XT-PL50] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Smart Plug 50', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_devices[XT-PL100] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Smart Plug 100', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_devices[XT-LK50] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Front Door Lock', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index 226d0bdbba91..5d013ac7f213 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -2,8 +2,8 @@ # name: test_doorbell_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'tmt100 Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index 3f89fe085253..f0c282c5eb1e 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'online_with_doorsense Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, diff --git a/tests/components/zinvolt/snapshots/test_init.ambr b/tests/components/zinvolt/snapshots/test_init.ambr index 657cb27c219b..5e1c9b893105 100644 --- a/tests/components/zinvolt/snapshots/test_init.ambr +++ b/tests/components/zinvolt/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device[BAT002] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'ZVS4000', 'name': 'Battery - 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'BAT002', 'sw_version': 'V1.20', 'via_device_id': , @@ -33,8 +32,8 @@ # name: test_device[ZVG011025120088] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'ZVS4000', 'name': 'Zinvolt Batterij', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ZVG011025120088', 'sw_version': 'V1.20', 'via_device_id': None, diff --git a/tests/conftest.py b/tests/conftest.py index 5fba335c33a7..92a94c4f04de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ import pytest_socket import requests_mock import respx from syrupy.assertion import SnapshotAssertion -from syrupy.session import SnapshotSession # Setup patching of JSON functions before any other Home Assistant imports from . import patch_json # isort:skip @@ -108,7 +107,7 @@ from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_han from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS -from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish +from .syrupy import HomeAssistantSnapshotExtension from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -173,11 +172,6 @@ def pytest_configure(config: pytest.Config) -> None: if config.getoption("verbose") > 0: logging.getLogger().setLevel(logging.DEBUG) - # Override default finish to detect unused snapshots despite xdist - # Temporary workaround until it is finalised inside syrupy - # See https://github.com/syrupy-project/syrupy/pull/901 - SnapshotSession.finish = override_syrupy_finish - class HASocketBlockedError(pytest_socket.SocketBlockedError): """SocketBlockedError variant which counts instances.""" @@ -2260,8 +2254,11 @@ DhcpServiceInfo.__init__ = _dhcp_service_info_init def disable_http_server() -> Generator[None]: """Disable automatic start of HTTP server during tests. - This prevents the HTTP server from starting in tests that setup - integrations which depend on the HTTP component. + This prevents the HTTP server from binding sockets and starting in tests + that setup integrations which depend on the HTTP component. """ - with patch("homeassistant.components.http.HomeAssistantHTTP.start"): + with ( + patch("homeassistant.components.http.HomeAssistantHTTP.async_bind"), + patch("homeassistant.components.http.HomeAssistantHTTP.start"), + ): yield diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 000000000000..d6431b3d1ae6 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; + +test("fresh instance redirects to onboarding and renders the UI", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page).toHaveURL(/\/onboarding\.html/); + await expect(page.locator("ha-onboarding")).toBeVisible(); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000000..bb69b43f05bd --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "home-assistant-e2e-tests", + "version": "1.0.0", + "description": "End-to-end browser tests for Home Assistant Core", + "private": true, + "packageManager": "pnpm@11.13.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000000..f130b643c4c0 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.BASE_URL ?? "http://localhost:8123"; + +export default defineConfig({ + testDir: ".", + timeout: 30_000, + // Reruns a failed test once in CI to absorb transient startup flakiness. + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml new file mode 100644 index 000000000000..51cd78654eff --- /dev/null +++ b/tests/e2e/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + +packages: + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 2da81a956021..d35a0affa7f0 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -2,8 +2,8 @@ # name: test_device_info_called DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'test-name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'test-sw', 'via_device_id': , @@ -37,8 +36,8 @@ # name: test_device_info_called.1 DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'test-name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'test-sw', 'via_device_id': , diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 262e700c29ed..5459020276c9 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -1,5 +1,7 @@ """Tests for the Device Utils.""" +from unittest.mock import patch + import pytest import voluptuous as vol @@ -103,7 +105,13 @@ async def test_device_info_to_link( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test for returning device info with device link information.""" + """The link helpers are deprecated and always return None. + + A device_info carrying another device's identifiers implicitly added the caller's + config entry to that device, which a single-config-entry device can't represent - it + would silently fork a duplicate instead. Entities still attach to another config + entry's device by setting entity.device_entry. + """ config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -112,7 +120,6 @@ async def test_device_info_to_link( connections={("mac", "30:31:32:33:34:00")}, config_entry_id=config_entry.entry_id, ) - assert device is not None # Source entity registry source_entity = entity_registry.async_get_or_create( @@ -125,33 +132,30 @@ async def test_device_info_to_link( await hass.async_block_till_done() assert entity_registry.async_get("sensor.test_source") is not None - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid=source_entity.entity_id - ) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } - - result = async_device_info_to_link_from_device_id(hass, device_id=device.id) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } + # No link device_info is returned, even for an existing entity and device + with patch("homeassistant.helpers.device.report_usage") as report_usage: + assert ( + async_device_info_to_link_from_entity( + hass, entity_id_or_uuid=source_entity.entity_id + ) + is None + ) + assert ( + async_device_info_to_link_from_device_id(hass, device_id=device.id) is None + ) + assert report_usage.call_count == 2 # With a non-existent entity id - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid="sensor.invalid" + assert ( + async_device_info_to_link_from_entity(hass, entity_id_or_uuid="sensor.invalid") + is None ) - assert result is None # With a non-existent device id - result = async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") is None # With a None device id - result = async_device_info_to_link_from_device_id(hass, device_id=None) - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id=None) is None async def test_remove_stale_device_links_keep_entity_device( diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 33da980715fd..cbe16456a8ac 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,9 +1,11 @@ """Tests for the Device Registry.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, nullcontext from datetime import datetime from functools import partial +import json +import pathlib import time from typing import Any from unittest.mock import ANY, patch @@ -28,6 +30,20 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_capture_events, flush_store +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + 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): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a mock config entry and add it to hass.""" @@ -160,10 +176,27 @@ async def test_requirement_for_identifier_or_connection( ) +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: + """Test async_get raises when the registry has not been set up.""" + with pytest.raises(RuntimeError, match="Device registry not set up"): + dr.async_get(hass) + + dr.async_setup(hass) + assert isinstance(dr.async_get(hass), dr.DeviceRegistry) + + +async def test_async_load_twice_raises(hass: HomeAssistant) -> None: + """Test loading the device registry twice raises.""" + registry = dr.async_get(hass) + with pytest.raises(RuntimeError, match="Device registry is already loaded"): + await registry.async_load() + + async def test_multiple_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" + """Test registering a device for multiple config entries with same identifiers.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -191,133 +224,70 @@ async def test_multiple_config_entries( model="model", ) - assert len(device_registry.devices) == 1 - assert entry.id == entry2.id + # Identifiers and connections are unique per config entry: the two config entries + # get separate devices, while re-registering for the first entry reuses its device + assert len(device_registry.devices) == 2 + assert entry.id != entry2.id assert entry.id == entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.primary_config_entry == config_entry_1.entry_id - assert entry3.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry3.primary_config_entry == config_entry_1.entry_id + assert entry.config_entry_id == config_entry_1.entry_id + assert entry2.config_entry_id == config_entry_2.entry_id async def test_multiple_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - config_entry_1 = MockConfigEntry( + """Test re-registering a device under different subentries of one config entry.""" + config_entry = MockConfigEntry( subentries_data=( config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), ) ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - entry_id = entry.id - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=None, + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - } - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - -@pytest.mark.parametrize("load_registries", [False]) -async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: - """Test async_get raises when the registry has not been set up.""" - with pytest.raises(RuntimeError, match="Device registry not set up"): - dr.async_get(hass) - - dr.async_setup(hass) - assert isinstance(dr.async_get(hass), dr.DeviceRegistry) - - -async def test_async_load_twice_raises(hass: HomeAssistant) -> None: - """Test loading the device registry twice raises.""" - registry = dr.async_get(hass) - with pytest.raises(RuntimeError, match="Device registry is already loaded"): - await registry.async_load() + # A device belongs to a single subentry; re-registering the same identifiers under + # another subentry of the same config entry moves the device rather than duplicating + assert len(device_registry.devices) == 1 + assert entry.id == entry2.id == entry3.id + assert entry2.config_subentry_id == "mock-subentry-id-2" + assert entry3.config_subentry_id == "mock-subentry-id-1" @pytest.mark.parametrize("load_registries", [False]) @@ -339,6 +309,12 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": created_at, @@ -365,6 +341,9 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "has_composite_identifiers": False, "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, @@ -375,6 +354,7 @@ async def test_loading_from_storage( "modified_at": modified_at, "name_by_user": "Test Friendly Name", "orphaned_timestamp": None, + "domain": None, } ], }, @@ -388,8 +368,8 @@ async def test_loading_from_storage( assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -410,8 +390,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, created_at=datetime.fromisoformat(created_at), @@ -427,7 +407,6 @@ async def test_loading_from_storage( modified_at=datetime.fromisoformat(modified_at), name_by_user="Test Friendly Name", name="name", - primary_config_entry=mock_config_entry.entry_id, serial_number="serial_no", sw_version="version", ) @@ -445,8 +424,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -457,7 +436,6 @@ async def test_loading_from_storage( model="model", modified_at=utcnow(), name_by_user="Test Friendly Name", - primary_config_entry=mock_config_entry.entry_id, ) assert entry.id == "bcdefghijklmn" assert isinstance(entry.config_entries, set) @@ -552,8 +530,12 @@ async def test_migration_from_1_1( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -576,8 +558,12 @@ async def test_migration_from_1_1( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -593,7 +579,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -602,8 +588,8 @@ async def test_migration_from_1_1( "deleted_devices": [ { "area_id": None, - "config_entries": ["123456"], - "config_entries_subentries": {"123456": [None]}, + "config_entry_id": "123456", + "config_subentry_id": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -614,6 +600,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "orphaned_timestamp": None, + "domain": None, } ], }, @@ -705,8 +692,12 @@ async def test_migration_from_1_2( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -729,8 +720,12 @@ async def test_migration_from_1_2( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -746,7 +741,7 @@ async def test_migration_from_1_2( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -842,8 +837,12 @@ async def test_migration_fom_1_3( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -866,8 +865,12 @@ async def test_migration_fom_1_3( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -883,7 +886,7 @@ async def test_migration_fom_1_3( "modified_at": "1970-01-01T00:00:00+00:00", "name": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -923,7 +926,7 @@ async def test_migration_from_1_4( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -981,8 +984,12 @@ async def test_migration_from_1_4( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1005,8 +1012,12 @@ async def test_migration_from_1_4( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1022,7 +1033,7 @@ async def test_migration_from_1_4( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1063,7 +1074,7 @@ async def test_migration_from_1_5( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -1122,8 +1133,12 @@ async def test_migration_from_1_5( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1146,8 +1161,12 @@ async def test_migration_from_1_5( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1163,7 +1182,7 @@ async def test_migration_from_1_5( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1222,7 +1241,7 @@ async def test_migration_from_1_6( "manufacturer": None, "model": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1265,8 +1284,12 @@ async def test_migration_from_1_6( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1289,8 +1312,12 @@ async def test_migration_from_1_6( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1306,7 +1333,7 @@ async def test_migration_from_1_6( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1367,7 +1394,7 @@ async def test_migration_from_1_7( "model": None, "model_id": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1410,8 +1437,12 @@ async def test_migration_from_1_7( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1434,8 +1465,12 @@ async def test_migration_from_1_7( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1451,7 +1486,7 @@ async def test_migration_from_1_7( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1556,8 +1591,12 @@ async def test_migration_from_1_10( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1582,8 +1621,9 @@ async def test_migration_from_1_10( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1693,8 +1733,12 @@ async def test_migration_from_1_11( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1719,8 +1763,9 @@ async def test_migration_from_1_11( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1737,11 +1782,1472 @@ async def test_migration_from_1_11( } +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_12( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.12. + + Version 3.1 restricts a device to a single config entry and subentry: a device + belonging to several config entries is split into one device per config entry (each + keeping a copy of the identifiers/connections and a legacy reference to the composite + id), while a device in several subentries of one config entry is collapsed onto a + single subentry (preferring a real subentry over the main entry). A device already + tied to a single config entry and subentry keeps its id. + """ + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + config_entry_3.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite device belonging to two config entries -> split in two + { + "area_id": "area_1", + "config_entries": [ + mock_config_entry.entry_id, + config_entry_2.entry_id, + ], + "config_entries_subentries": { + mock_config_entry.entry_id: [None], + config_entry_2.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite0000000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + }, + # Composite device spanning several subentries of one config entry -> + # split into one device per subentry (including the no-subentry one) + { + "area_id": None, + "config_entries": [config_entry_3.entry_id], + "config_entries_subentries": { + config_entry_3.entry_id: [ + None, + "mock-subentry-id-1", + "mock-subentry-id-2", + ] + }, + "configuration_url": None, + "connections": [["mac", "34:56:78:cd:ef:12"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "subentries00000000000000000000", + "identifiers": [["domain_c", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": config_entry_3.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Single (config entry, subentry) device -> keeps its id, no legacy ref + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "singleentry00000000000000000000", + "identifiers": [["domain_a", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The single (config entry, subentry) device keeps its id and has no legacy reference + single = registry.async_get("singleentry00000000000000000000") + assert single is not None + assert single.config_entry_id == mock_config_entry.entry_id + assert single.config_subentry_id is None + assert single.composite_device_id is None + 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 + entry_splits = registry.async_get_devices_for_composite_device_id( + "composite0000000000000000000000" + ) + assert len(entry_splits) == 2 + assert {(d.config_entry_id, d.config_subentry_id) for d in entry_splits} == { + (mock_config_entry.entry_id, None), + (config_entry_2.entry_id, None), + } + for device in entry_splits: + assert device.id != "composite0000000000000000000000" + # Each split copies the identity and customizations of the composite ... + assert device.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert device.connections == {("mac", "12:34:56:ab:cd:ef")} + assert device.area_id == "area_1" + assert device.name_by_user == "custom name" + assert device.labels == {"lab"} + assert device.serial_number == "SERIAL" + # ... and records its composite_device_id, keeping the copied identifiers + assert device.composite_device_id == "composite0000000000000000000000" + assert device.composite_primary_config_entry == mock_config_entry.entry_id + assert device.split_at is not None + assert device.has_composite_identifiers is True + + # A device spanning several subentries of ONE config entry is an invalid state (only + # a buggy 2025.7 subentry migration produced it); it is collapsed to a single device + # 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 ( + registry.async_get_devices_for_composite_device_id( + "subentries00000000000000000000" + ) + == [] + ) + collapsed = _get_device_for_config_entry( + registry, config_entry_3.entry_id, identifiers={("domain_c", "1")} + ) + assert collapsed is not None + assert collapsed.id == "subentries00000000000000000000" + assert collapsed.config_entry_id == config_entry_3.entry_id + assert collapsed.config_subentry_id == "mock-subentry-id-1" + assert collapsed.identifiers == {("domain_c", "1")} + assert collapsed.connections == {("mac", "34:56:78:cd:ef:12")} + assert collapsed.composite_device_id is None + assert collapsed.has_composite_identifiers is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_backs_up_store_file( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_tmp_config_dir: str, +) -> None: + """The store file is copied to a timestamped backup before the version 3 migration.""" + hass.config.config_dir = hass_tmp_config_dir + storage_dir = pathlib.Path(hass_tmp_config_dir) / ".storage" + storage_dir.mkdir(parents=True, exist_ok=True) + old_store = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": {"devices": [], "deleted_devices": []}, + } + (storage_dir / dr.STORAGE_KEY).write_text(json.dumps(old_store)) + hass_storage[dr.STORAGE_KEY] = old_store + + dr.async_setup(hass) + await dr.async_load(hass) + + # Exactly one timestamped copy of the pre-migration file was made + backups = list(storage_dir.glob(f"{dr.STORAGE_KEY}.*.migration_backup")) + assert len(backups) == 1 + assert json.loads(backups[0].read_text()) == old_store + # The middle segment is a YYYYMMDD_HHMMSS timestamp (strptime raises if malformed) + datetime.strptime(backups[0].name.split(".")[-2], "%Y%m%d_%H%M%S") + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_detaches_via_device_of_dropped_parent( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child of an ownerless parent dropped by the migration has its link detached. + + The migration drops an active device with no config entry; normally + async_remove_device would clear via_device_id links to it, so the migration must too. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + + def _device(**overrides: Any) -> dict[str, Any]: + device = { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device0000000000000000000000000", + "identifiers": [["test", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + return device | overrides + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Ownerless parent (no config entries) -> dropped by the migration + _device( + id="orphan0000000000000000000000000", + config_entries=[], + config_entries_subentries={}, + identifiers=[["test", "orphan"]], + primary_config_entry=None, + ), + # Child linked to the orphan via via_device_id + _device( + id="child00000000000000000000000000", + identifiers=[["test", "child"]], + via_device_id="orphan0000000000000000000000000", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The ownerless parent was dropped; the child survives with its link detached + assert registry.async_get("orphan0000000000000000000000000") is None + child = registry.async_get("child00000000000000000000000000") + assert child is not None + assert child.via_device_id is None + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_collapses_multi_subentry_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device wrongly assigned to several subentries of one config entry collapses. + + Only a buggy 2025.7 subentry migration produced this state. The migration must + collapse it to a single device (preferring a real subentry over the main entry, + None), NOT split it into duplicate devices sharing the same identifiers/connections. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Sub 1", + unique_id="s1", + ), + ] + ) + entry.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None, "sub-1"]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "buggydevice00000000000000000", + "identifiers": [["test", "device-1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Collapsed to a single device (no duplicate), on the real subentry, keeping its id + assert len(registry.devices) == 1 + device = registry.async_get("buggydevice00000000000000000") + assert device is not None + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id == "sub-1" + assert device.config_entries_subentries == {entry.entry_id: {"sub-1"}} + # It is not split and stays findable by identifier and connection (not shadowed) + assert ( + registry.async_get_devices_for_composite_device_id( + "buggydevice00000000000000000" + ) + == [] + ) + assert device.composite_device_id is None + assert device.has_composite_identifiers is False + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, identifiers={("test", "device-1")} + ) + is device + ) + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, connections={("mac", "12:34:56:ab:cd:ef")} + ) + is device + ) + + +async def test_async_get_or_create_moves_device_between_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Re-registering under a different subentry moves the device, not duplicates it. + + Identifiers and connections are unique per config entry (not per subentry), so a + second async_get_or_create with the same identifier/connection but a different + subentry of the same config entry moves the existing device - it neither creates a + duplicate nor raises. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="1", + unique_id="s1", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="2", + unique_id="s2", + ), + ] + ) + entry.add_to_hass(hass) + + # Same identifier, different subentry -> the existing device is moved + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + identifiers={("test", "1")}, + ) + assert device.config_subentry_id == "sub-1" + moved = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + identifiers={("test", "1")}, + ) + assert moved.id == device.id + assert moved.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 1 + + # Same connection, different subentry -> also moved, not duplicated + device_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + moved_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + assert moved_2.id == device_2.id + assert moved_2.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 2 + + +async def test_async_get_device_returns_first_match_for_ambiguous_lookup( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Independent devices sharing an identifier resolve to the first match. + + They are not splits of one pre-migration composite (no shared composite_device_id), + so there is nothing to merge and the lookup returns one of the real devices rather + than a composite. + """ + 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", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + assert device_1.id != device_2.id + + 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.config_entries == {entry_1.entry_id} + + +async def test_async_get_device_prefers_calling_integration( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An ambiguous lookup prefers a device owned by the calling integration.""" + entry_a = MockConfigEntry(domain="itg_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="itg_b") + entry_b.add_to_hass(hass) + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + # itg_a's device is indexed first (created first) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, connections={mac} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, connections={mac} + ) + assert device_a.id != device_b.id + + # Each integration resolves to its own device, regardless of index order + with patch.object(dr, "_current_integration_domain", return_value="itg_b"): + assert device_registry.async_get_device(connections={mac}) is device_b + with patch.object(dr, "_current_integration_domain", return_value="itg_a"): + assert device_registry.async_get_device(connections={mac}) is device_a + + # A caller owning neither, or no integration frame, falls back to the first match + with patch.object(dr, "_current_integration_domain", return_value="other"): + assert device_registry.async_get_device(connections={mac}) is device_a + with patch.object(dr, "_current_integration_domain", return_value=None): + assert device_registry.async_get_device(connections={mac}) is device_a + + +async def test_async_get_device_prefers_matching_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A lookup prefers the device whose config entry domain matches the identifier. + + Right after the migration split, and until identifiers are pruned, every split still + carries the composite's full identifier set, so a lookup matches all splits; the + domain match resolves it to the correct single device without a composite. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + # entry_b's device also carries domain_a's identifier (unpruned split state) + device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, + identifiers={("domain_a", "1"), ("domain_b", "2")}, + ) + assert device_registry.async_get_device(identifiers={("domain_a", "1")}) is device_a + + +async def test_async_remove_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_remove_device on a pre-migration composite id removes its splits.""" + 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")} + ) + 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 + ) + + 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 + + +async def test_async_update_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_update_device on a pre-migration composite id fans out to its splits.""" + 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")} + ) + 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 + ) + + device_registry.async_update_device(old_id, name_by_user="merged") + + assert device_registry.async_get(device_1.id).name_by_user == "merged" + assert device_registry.async_get(device_2.id).name_by_user == "merged" + + +async def test_get_entry_by_connection_without_config_entry_scope( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The container resolves by connection when no config entry scope is given.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + 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 + + +async def test_update_unknown_device_id_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Updating an id that is neither a real device nor a composite raises.""" + with pytest.raises(KeyError): + device_registry.async_update_device("unknown0000000000000000000000ab", name="x") + + +async def test_cleanup_removes_device_referencing_missing_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Cleanup drops a device still referencing a config entry that no longer exists.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} + ) + # An entity keeps the device out of the plain-orphan sweep so the defensive + # missing-config-entry path is reached + entity_registry.async_get_or_create("sensor", "test", "unique", device_id=device.id) + + # The device's config entry is no longer known to hass + 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 + + +async def test_clear_config_entry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning config + entry must remove the device rather than complete that move to the other entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + device_registry.async_clear_config_entry(entry_1.entry_id) + + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + + +async def test_clear_config_entry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry drops a pending move that targets it. + + A device owned by another entry can hold a transient pending move to the entry being + removed; clearing it stops a later completion from moving the device onto the removed + entry instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move to entry_2 (add_config_entry_id without the paired remove yet) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + # entry_2 is torn down before the move completes + device_registry.async_clear_config_entry(entry_2.entry_id) + + # Completing the move by removing the owner must delete the device, not move it onto + # the removed entry_2 + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +async def test_move_to_config_entry_clears_target_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device into a config entry clears a matching deleted device it holds. + + A retained-identity move adds no new identifiers/connections, so the deleted device the + target entry kept for the same identity must still be removed - otherwise the active + device and the deleted device share the target entry's per-identity slot. + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "shared")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert device_a.id != device_b.id + + # 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 + + # Move device_a into entry_b, retaining its identity + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_b.entry_id + ) + + 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 + + +async def test_get_or_create_via_device_and_via_device_id_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Passing both via_device and via_device_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="not allowed"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "1")}, + via_device=("test", "via"), + via_device_id="via-device-id", + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_get_or_create_invalid_subentry_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An unknown config_subentry_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="has no subentry"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="does-not-exist", + identifiers={("test", "1")}, + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_add_current_config_entry_is_noop( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding the device's current owner records no pending move. + + So a later removal of that sole owner deletes the device instead of moving it to + itself. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} + ) + + device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id) + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + assert result is None + assert device.id not in device_registry.devices + + +@pytest.mark.parametrize( + "clear_domain", + ["light", None], + ids=["explicit-domain", "auto-resolved-domain"], +) +async def test_reregister_restores_orphan( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + clear_domain: str | None, +) -> None: + """Re-adding an integration restores its orphan. + + async_clear_config_entry records the config entry's domain - passed in by the core + removal flow, or resolved from the still-present entry when omitted - and a later + async_get_or_create under the same domain restores that orphan (id, labels, name) + rather than create a fresh device. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")}, name="Original" + ) + device_registry.async_update_device( + device.id, name_by_user="Custom", labels={"label1"} + ) + + # 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] + assert orphan.config_entry_id is None + assert orphan.domain == "light" + + # Re-add the integration under a new config entry and re-register the device + new_entry = MockConfigEntry(domain="light") + new_entry.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=new_entry.entry_id, identifiers={("light", "1")} + ) + + assert restored.id == device.id + assert restored.config_entry_id == new_entry.entry_id + assert restored.name_by_user == "Custom" + assert restored.labels == {"label1"} + + +async def test_orphan_not_restored_for_other_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan recorded for one integration is not restored by another. + + Identifiers and connections are no longer unique across integrations, so a chance + collision must not restore another integration's orphaned device onto this one. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + 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" + + # 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 + other_entry = MockConfigEntry(domain="switch") + other_entry.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("light", "1")} + ) + assert fresh.id != device.id + assert device.id in device_registry.deleted_devices + + +async def test_orphaning_replaces_colliding_same_domain_orphan( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Orphaning a device drops a stale same-domain orphan it collides with. + + Two devices from the same integration sharing a connection both orphan under + config_entry_id=None and would collide in the lookup index; the newest orphan replaces + the stale one so a re-add restores it deterministically. + """ + connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")} + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + connections=connections, + identifiers={("hue", "1")}, + ) + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + + device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain) + 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 + + # Re-adding under the same domain restores the surviving orphan + entry_3 = MockConfigEntry(domain="hue") + entry_3.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=entry_3.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + assert restored.id == device_2.id + + +async def test_orphaned_domain_survives_store_round_trip( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan's recorded domain is written to and read back from storage.""" + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} + ) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) + + registry2 = dr.DeviceRegistry(hass) + await flush_store(device_registry._store) + await registry2.async_load() + + assert registry2.deleted_devices[device.id].domain == "hue" + + +async def test_orphan_keeps_domain_when_config_entry_removed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan keeps its domain when its config entry is removed via the normal flow. + + config_entries deletes the entry from the registry before calling + async_clear_config_entry, so async_remove_device can no longer look up the domain and + records None; the domain passed to async_clear_config_entry is what preserves it on + the orphan. Without it the orphan would have domain=None and, with the domain-less + restore fallback gone, could never be restored. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} + ) + + await hass.config_entries.async_remove(entry.entry_id) + + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "hue" + + +async def test_cross_domain_orphans_do_not_shadow( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Orphans from different integrations sharing an identifier stay independently found. + + Both orphans would otherwise collide in the config_entry_id=None index; keying orphans + by their recorded domain keeps each restorable by its own integration. + """ + shared = {("test", "shared")} + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers=shared + ) + entry_b = MockConfigEntry(domain="mqtt") + entry_b.add_to_hass(hass) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers=shared + ) + + device_registry.async_clear_config_entry(entry_a.entry_id, entry_a.domain) + device_registry.async_clear_config_entry(entry_b.entry_id, entry_b.domain) + + # Re-adding under each domain restores that domain's own orphan, not the other's + entry_c = MockConfigEntry(domain="mqtt") + entry_c.add_to_hass(hass) + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_c.entry_id, identifiers=shared + ) + assert restored_b.id == device_b.id + + entry_d = MockConfigEntry(domain="hue") + entry_d.add_to_hass(hass) + restored_a = device_registry.async_get_or_create( + config_entry_id=entry_d.entry_id, identifiers=shared + ) + assert restored_a.id == device_a.id + + +async def test_domainless_orphan_not_restored( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A domain-less orphan is not restored; re-registering creates a fresh device. + + The migration carries orphans over without a domain, which can't be resolved once the + config entry is gone. Orphans are matched only on their recorded domain, so a + domain-less one is left for the periodic purge and re-registering makes a new device. + """ + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + + # Simulate an orphan whose domain can no longer be resolved (the migration carries + # 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 + + # Re-registering the shared identifier does not restore the domain-less orphan + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + assert fresh.id != device_1.id + # The un-restored orphan lingers until the periodic purge + assert device_1.id in device_registry.deleted_devices + + +async def test_clear_config_subentry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning + subentry must remove the device rather than complete that move. + """ + entry_1 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("test", "1")}, + ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1") + + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + + +async def test_clear_config_subentry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry drops a pending move that targets it. + + A device owned by another entry can hold a transient pending move to the subentry being + removed; clearing it stops a later completion from validating against the removed + subentry (moving the device onto it, or raising) instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move into entry_2's subentry + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + + # The target subentry is torn down before the move completes + device_registry.async_clear_config_subentry(entry_2.entry_id, "mock-subentry-id-1") + + # Completing the move by removing the owner must delete the device, not move it onto + # the removed subentry + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_device_composite_reuses_pre_migration_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A composite over migration splits reuses the pre-migration device id. + + Backwards compatibility for unmodified integrations: before the rewrite a shared + connection resolved to one device with a stable id that stored references + (automations, an entity device_id, a fired event device_id) use. The composite over + that device's splits reuses the same id, so those references keep resolving; a + transient id is minted only for a runtime ambiguity between independent devices. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "aa:bb:cc:dd:ee:ff"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # A connections-only lookup matches both splits -> composite reuses the old id + composite = registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + ) + assert composite is not None + assert composite.id == "composite00000000000000000000" + 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.config_entry_id == entry_a.entry_id + + +@pytest.mark.parametrize( + "update_kwargs", + [ + pytest.param({"new_identifiers": {("test", "new")}}, id="new_identifiers"), + pytest.param( + {"new_connections": {("mac", "12:34:56:ab:cd:ef")}}, id="new_connections" + ), + pytest.param( + {"merge_identifiers": {("test", "extra")}}, id="merge_identifiers" + ), + pytest.param( + {"merge_connections": {("mac", "12:34:56:ab:cd:ef")}}, + id="merge_connections", + ), + ], +) +async def test_async_update_device_composite_drops_identity_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + update_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Identity-rewriting args are ambiguous on a composite: dropped with a warning.""" + 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")} + ) + 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 + ) + + # No raise; the arg is ignored with a report-issue warning, devices untouched + device_registry.async_update_device(old_id, **update_kwargs) + assert "async_entries_for_config_entry" in caplog.text + assert "report this issue" in caplog.text + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_1.id).connections == set() + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + assert device_registry.async_get(device_2.id).connections == set() + + +async def test_async_update_device_composite_drops_only_disallowed_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A composite update applies the allowed args and drops the disallowed ones.""" + 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")} + ) + 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 + ) + + device_registry.async_update_device( + old_id, + new_identifiers={("test", "renamed")}, # disallowed -> dropped + name_by_user="Custom name", # allowed -> applied to every underlying device + ) + assert "new_identifiers" in caplog.text + # Allowed arg applied to both underlying devices + assert device_registry.async_get(device_1.id).name_by_user == "Custom name" + assert device_registry.async_get(device_2.id).name_by_user == "Custom name" + # Disallowed arg dropped: identities untouched + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + + +async def test_async_update_device_composite_drops_move_args( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """new_config_entry_id / new_config_subentry_id are dropped on the composite path. + + A forwarded move can't be caught by the identifier/connection checks - the splits have + distinct identities and would move without colliding - so assert each split keeps its + original (config entry, subentry). + """ + entry_1 = MockConfigEntry( + domain="test", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, subentry_type="test", title="Sub", unique_id=None + ) + ], + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + subentry_id = next(iter(entry_1.subentries)) + 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")} + ) + old_id = "composite00000000000000000000ab" + 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 + ) + + # Targets are valid, so a forwarded move would land silently - only the owner + # assertions below catch it. + device_registry.async_update_device(old_id, new_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(old_id, new_config_subentry_id=subentry_id) + + assert device_registry.async_get(device_1.id).config_entry_id == entry_1.entry_id + assert device_registry.async_get(device_1.id).config_subentry_id is None + assert device_registry.async_get(device_2.id).config_entry_id == entry_2.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_drops_device_without_config_entries( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """A device with no config entry / subentry pairs is dropped during migration.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Orphan device with no config entries -> dropped + { + "area_id": None, + "config_entries": [], + "config_entries_subentries": {}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "orphan00000000000000000000000", + "identifiers": [["domain_a", "orphan"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": None, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Normal single-config-entry device -> kept + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "keptdevice0000000000000000000", + "identifiers": [["domain_a", "kept"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The orphan device was dropped, the normal device kept + assert registry.async_get("orphan00000000000000000000000") is None + 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 + assert len(registry.devices) == 1 + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_splits_deleted_device_with_multiple_config_entries( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A deleted device belonging to several config entries is split, one per entry. + + Each split keeps the identity and customizations so every config entry can still + restore its share when a matching device is re-registered. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [], + "deleted_devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, + "id": "deletedcomposite0000000000000", + "identifiers": [["domain_a", "1"]], + "labels": ["lab"], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "orphaned_timestamp": None, + } + ], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + 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 set(by_entry) == {entry_a.entry_id, entry_b.entry_id} + for deleted in by_entry.values(): + assert deleted.identifiers == {("domain_a", "1")} + assert deleted.connections == {("mac", "12:34:56:ab:cd:ef")} + assert deleted.name_by_user == "custom name" + assert deleted.area_id == "area_1" + + # Each config entry can restore its share, with the customizations preserved + restored_a = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_a.config_entry_id == entry_a.entry_id + assert restored_a.name_by_user == "custom name" + + restored_b = registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_b.config_entry_id == entry_b.entry_id + assert restored_b.name_by_user == "custom name" + assert restored_a.id != restored_b.id + + async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry removes the devices that belong to it.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -1751,86 +3257,36 @@ async def test_removing_config_entries( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry3 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id + # Same identifiers on different config entries are separate devices + assert len(device_registry.devices) == 3 + assert entry.id != entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } device_registry.async_clear_config_entry(config_entry_1.entry_id) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - entry3_removed = device_registry.async_get_device( - identifiers={("bridgeid", "4567")} - ) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} - assert entry3_removed is None - - await hass.async_block_till_done() - - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + # Clearing config_entry_1 removes its two devices, leaving config_entry_2's + assert len(device_registry.devices) == 1 + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry3.id) is None + assert device_registry.async_get(entry2.id) is not None async def test_deleted_device_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry orphans its deleted devices.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -1840,536 +3296,137 @@ async def test_deleted_device_removing_config_entries( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } - device_registry.async_remove_device(entry.id) - device_registry.async_remove_device(entry3.id) - + device_registry.async_remove_device(entry2.id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 - await hass.async_block_till_done() - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } - device_registry.async_clear_config_entry(config_entry_1.entry_id) - assert len(device_registry.devices) == 0 + + # Deleted devices are kept but orphaned (config entry cleared) so they can be purged assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} + assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert ( + 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.devices) == 0 assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - - # Re-add, expect to keep the device id - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - - assert entry.id == entry2.id - - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 - - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - - # Re-add, expect to get a new device id after the purge - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry3.id != entry4.id + assert device_registry.deleted_devices[entry2.id].config_entry_id is None async def test_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry removes the devices that belong to it.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) + assert len(device_registry.devices) == 2 + assert entry.config_subentry_id == "mock-subentry-id-1" + assert entry2.config_subentry_id == "mock-subentry-id-2" + + device_registry.async_clear_config_subentry( + config_entry.entry_id, "mock-subentry-id-1" + ) + + # Only the device on the cleared subentry is removed assert len(device_registry.devices) == 1 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - device_registry.async_update_device( - entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - assert device_registry.async_get_device(identifiers={("bridgeid", "0123")}) is None - assert device_registry.async_get_device(identifiers={("bridgeid", "4567")}) is None - - await hass.async_block_till_done() - - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry2.id) is not None async def test_deleted_device_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry orphans its deleted devices.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - 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.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - await hass.async_block_till_done() - - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry.id, - "device": entry4.dict_repr, - } - - device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - # Remove the same subentry again device_registry.async_clear_config_subentry( - config_entry_1.entry_id, "mock-subentry-id-1-1" + 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 ( - device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) is entry + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry.entry_id ) - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None - - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - - # Re-add, expect to keep the device id - hass.config_entries.async_add_subentry( - config_entry_2, - config_entries.ConfigSubentry( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - restored_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert restored_entry.id == entry.id - - # Remove again, and trigger purge - device_registry.async_remove_device(entry.id) - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None - - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 - - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 - - # Re-add, expect to get a new device id after the purge - new_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert new_entry.id != entry.id - async def test_removing_area_id( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry @@ -2554,6 +3611,167 @@ async def test_specifying_via_device_update( assert light.name == "New light" +async def test_get_or_create_via_device_and_via_device_id_not_allowed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Passing both via_device and via_device_id is not allowed.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) + + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + via_device_id=via.id, + ) + + # Passing only via_device_id is allowed + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device_id=via.id, + ) + assert device.via_device_id == via.id + + # Passing only the deprecated via_device is still allowed (resolved to via_device_id) + device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device_2")}, + via_device=("hue", "via"), + ) + assert device_2.via_device_id == via.id + + +async def test_get_or_create_via_device_none( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """`via_device=None` means "no via device"; combining it with via_device_id raises.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) + + # `via_device=None` alongside a via_device_id is contradictory and rejected + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + via_device_id=via.id, + ) + + # `via_device=None` on its own means no via device + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + ) + assert device.via_device_id is None + + # ... and it clears an existing via device on re-registration + linked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device_id=via.id, + ) + assert linked.via_device_id == via.id + relinked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device=None, + ) + assert relinked.id == linked.id + assert relinked.via_device_id is None + + +async def test_via_device_prefers_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device resolves to the via device in the same config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # Two via devices share an identifier, one per config entry + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + via_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("hue", "via")} + ) + assert via_1.id != via_2.id + + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_2.id + + +async def test_via_device_falls_back_to_other_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device falls back to a via device in another config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # The via device only exists in entry_1 + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_1.id + + +async def test_via_device_prefers_same_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device prefers a via device from the same integration. + + When no via device exists in the registering config entry, one from another config + entry of the same domain is preferred over an arbitrary other-domain match. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + other_domain_entry = MockConfigEntry(domain="deconz") + other_domain_entry.add_to_hass(hass) + same_domain_entry = MockConfigEntry(domain="hue") + same_domain_entry.add_to_hass(hass) + + # No via device in `entry`; the other-domain candidate is indexed first + device_registry.async_get_or_create( + config_entry_id=other_domain_entry.entry_id, identifiers={("hue", "via")} + ) + via_same_domain = device_registry.async_get_or_create( + config_entry_id=same_domain_entry.entry_id, identifiers={("hue", "via")} + ) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_same_domain.id + + async def test_loading_saving_data( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -2641,7 +3859,10 @@ async def test_loading_saving_data( suggested_area="Kitchen", ) - assert len(device_registry.devices) == 4 + # config_entry_4's device shares a connection with orig_light3 but belongs to a + # 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 orig_via = device_registry.async_update_device( @@ -2793,8 +4014,8 @@ async def test_update( assert updated_entry != entry assert updated_entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("mac", "65:43:21:fe:dc:ba")}, created_at=created_at, @@ -2975,411 +4196,62 @@ async def test_update_connection( async def test_update_remove_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + """Test removing a device's config entry deletes the device.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_update_device( - entry2.id, add_config_entry_id=config_entry_3.entry_id - ) - # Try to add an unknown config entry - with pytest.raises(HomeAssistantError): - device_registry.async_update_device(entry2.id, add_config_entry_id="blabla") + assert entry.config_entry_id == config_entry.entry_id - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id == entry4.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - - device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_1.entry_id - ) - updated_entry = device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_3.entry_id - ) - removed_entry = device_registry.async_update_device( - entry3.id, remove_config_entry_id=config_entry_1.entry_id + # Removing the owning config entry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry.entry_id ) - assert updated_entry.config_entries == {config_entry_2.entry_id} - assert removed_entry is None - - removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) - - assert removed_entry is None - - await hass.async_block_till_done() - - assert len(update_events) == 7 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[6].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 async def test_update_remove_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test removing a device's config subentry deletes the device.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-2", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry_id = entry.id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - } + assert entry.config_subentry_id == "mock-subentry-id-1" - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - # Try adding the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - is entry + # Removing the owning config entry/subentry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, + remove_config_entry_id=config_entry.entry_id, + remove_config_subentry_id="mock-subentry-id-1", ) - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_2.entry_id, - add_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id=None, - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - # Try to add a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't add config subentry without specifying config entry", - ): - device_registry.async_update_device(entry_id, add_config_subentry_id="blabla") - - # Try to add an unknown subentry - with pytest.raises( - HomeAssistantError, - match=f"Config entry {config_entry_3.entry_id} has no subentry blabla", - ): - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id="blabla", - ) - - # Try to remove a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't remove config subentry without specifying config entry", - ): - device_registry.async_update_device( - entry_id, remove_config_subentry_id="blabla" - ) - - assert len(device_registry.devices) == 1 - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - # Try removing the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - is entry - ) - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_2.entry_id, config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_2.entry_id, - remove_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_3.entry_id: {None}, - } - - entry_before_remove = entry - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_3.entry_id, - remove_config_subentry_id=None, - ) - assert entry is None - - await hass.async_block_till_done() - - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry_id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry_id, - "device": entry_before_remove.dict_repr, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 @pytest.mark.parametrize( @@ -3452,202 +4324,85 @@ async def test_update_suggested_area( @pytest.mark.parametrize( - ( - "new_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), + "device_disabled_by", [ - ( - None, - None, - None, - {}, - ), - # Config entry not disabled, device was disabled by config entry. - # Device not disabled when updated. - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - None, - {"disabled_by": dr.DeviceEntryDisabler.CONFIG_ENTRY}, - ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - None, - None, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, ], ) @pytest.mark.usefixtures("freezer") async def test_update_add_config_entry_disabled_by( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - new_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_updated: dr.DeviceEntryDisabler | None, - extra_changes: dict[str, Any], + device_disabled_by: dr.DeviceEntryDisabler | None, ) -> None: - """Check how the disabled_by flag is treated when adding a config entry.""" + """Check how the disabled_by flag is treated when adding a config entry. + + A device is now owned by a single config entry: add_config_entry_id only records a + transient pending move (completed by a subsequent remove of the current owner), so on + its own it leaves the device - including its disabled_by flag - unchanged. + """ config_entry_1 = MockConfigEntry(title=None) config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - title=None, disabled_by=new_config_entry_disabled_by - ) + config_entry_2 = MockConfigEntry(title=None) config_entry_2.add_to_hass(hass) update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, config_subentry_id=None, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, + disabled_by=device_disabled_by, ) - assert entry.disabled_by == device_disabled_by_initial + assert entry.disabled_by == device_disabled_by entry2 = device_registry.async_update_device( entry.id, add_config_entry_id=config_entry_2.entry_id ) - assert entry2 == dr.DeviceEntry( - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_updated, - id=entry.id, - modified_at=utcnow(), - primary_config_entry=None, - ) + # The device is unchanged: still owned by config_entry_1, same disabled_by + assert entry2.config_entry_id == config_entry_1.entry_id + assert entry2.config_subentry_id is None + assert entry2.disabled_by == device_disabled_by await hass.async_block_till_done() - assert len(update_events) == 2 + # The pending move is never stored, so no update event is fired + assert len(update_events) == 1 assert update_events[0].data == { "action": "create", "device_id": entry.id, } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - } - | extra_changes, - } @pytest.mark.parametrize( - ( - "removed_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), + ("device_disabled_by", "expected_disabled_by"), [ - # The non-disabled config entry is removed, device changed to - # disabled by config entry. - ( - None, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {"disabled_by": None}, - ), - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - # In this test, the device is in an invalid state: config entry disabled, - # device not disabled. After removing the config entry, the device is disabled - # by checking the remaining config entry. - ( - config_entries.ConfigEntryDisabler.USER, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {"disabled_by": None}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), + # An enabled device moved onto a disabled entry is disabled by CONFIG_ENTRY + (None, dr.DeviceEntryDisabler.CONFIG_ENTRY), + # An existing CONFIG_ENTRY / INTEGRATION / USER disable is preserved + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), ], ) @pytest.mark.usefixtures("freezer") async def test_update_remove_config_entry_disabled_by( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - removed_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_updated: dr.DeviceEntryDisabler | None, - extra_changes: dict[str, Any], + device_disabled_by: dr.DeviceEntryDisabler | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, ) -> None: - """Check how the disabled_by flag is treated when removing a config entry.""" - config_entry_1 = MockConfigEntry( - title=None, disabled_by=removed_config_entry_disabled_by - ) + """Check how the disabled_by flag is treated when removing a config entry. + + add_config_entry_id followed by remove_config_entry_id of the current owner moves the + device to the added config entry. The move re-evaluates disabled_by against the new + owning entry (like restoring a deleted device): an enabled device moved onto a + disabled entry becomes CONFIG_ENTRY-disabled, while a USER/INTEGRATION disable - or an + existing CONFIG_ENTRY disable - is kept. + """ + config_entry_1 = MockConfigEntry(title=None) config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry( title=None, disabled_by=config_entries.ConfigEntryDisabler.USER @@ -3658,57 +4413,525 @@ async def test_update_remove_config_entry_disabled_by( config_entry_id=config_entry_1.entry_id, config_subentry_id=None, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, + disabled_by=device_disabled_by, ) - assert entry.disabled_by == device_disabled_by_initial + assert entry.disabled_by == device_disabled_by - entry2 = device_registry.async_update_device( + # add records a pending move, remove of the current owner performs it + device_registry.async_update_device( entry.id, add_config_entry_id=config_entry_2.entry_id ) - assert entry2.disabled_by == device_disabled_by_initial - entry3 = device_registry.async_update_device( entry.id, remove_config_entry_id=config_entry_1.entry_id ) - assert entry3 == dr.DeviceEntry( - config_entries={config_entry_2.entry_id}, - config_entries_subentries={config_entry_2.entry_id: {None}}, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_updated, - id=entry.id, - modified_at=utcnow(), - primary_config_entry=None, - ) + # The device moved to config_entry_2, disabled_by reflecting the new entry + assert entry3 is not None + assert entry3.config_entry_id == config_entry_2.entry_id + assert entry3.config_subentry_id is None + assert entry3.disabled_by == expected_disabled_by await hass.async_block_till_done() - assert len(update_events) == 3 + # create + the move update (the add on its own does not fire an event) + assert len(update_events) == 2 assert update_events[0].data == { "action": "create", "device_id": entry.id, } + expected_changes: dict[str, Any] = {"config_entry_id": config_entry_1.entry_id} + if expected_disabled_by != device_disabled_by: + expected_changes["disabled_by"] = device_disabled_by assert update_events[1].data == { "action": "update", "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, + "changes": expected_changes, + } + + +async def test_move_to_enabled_config_entry_clears_config_entry_disable( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device to an enabled config entry clears a CONFIG_ENTRY disable. + + The reverse of moving onto a disabled entry; a USER disable is preserved. + """ + disabled_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + ) + disabled_entry.add_to_hass(hass) + enabled_entry = MockConfigEntry() + enabled_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "1")}, + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + device_registry.async_update_device( + device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved is not None + assert moved.config_entry_id == enabled_entry.entry_id + assert moved.disabled_by is None + + user_device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "2")}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + device_registry.async_update_device( + user_device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved_user = device_registry.async_update_device( + user_device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved_user is not None + assert moved_user.disabled_by is dr.DeviceEntryDisabler.USER + + +async def test_move_to_config_entry_with_colliding_identity_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device onto a config entry that already has its identity raises. + + Identifiers and connections are unique per config entry, so a move must validate the + device's retained identity against the target entry instead of silently overwriting + the existing device's index slot. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_2.entry_id + ) + + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device_c = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, connections={mac} + ) + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, connections={mac} + ) + with pytest.raises(dr.DeviceConnectionCollisionError): + device_registry.async_update_device( + device_c.id, new_config_entry_id=entry_2.entry_id + ) + + +async def test_add_identifier_keeps_other_config_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding an identifier does not delete a matching deleted device of another entry. + + Deleted devices are per config entry now, so a device in entry A merging an + identifier must not wipe entry B's deleted-device metadata (its restore data). + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "a")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + device_registry.async_update_device(device_b.id, name_by_user="Custom B") + device_b_id = device_b.id + device_registry.async_remove_device(device_b.id) + + # entry A's device merges the identifier entry B's deleted device also has + device_registry.async_update_device( + device_a.id, merge_identifiers={("test", "shared")} + ) + + # entry B's deleted device survives, so re-registering restores its id and metadata + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert restored_b.id == device_b_id + assert restored_b.name_by_user == "Custom B" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_remaps_via_device_id_to_split( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child's via_device_id is remapped to a live parent split. + + To the split in the child's own config entry when the parent spanned it, otherwise to + one of the parent's splits - never left dangling on the removed composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="dom_c") + entry_c.add_to_hass(hass) + + def _device(id_: str, entries: list[str], identifiers, via: str | None) -> dict: + return { + "area_id": None, + "config_entries": entries, + "config_entries_subentries": {entry: [None] for entry in entries}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": id_, + "identifiers": identifiers, + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entries[0], + "serial_number": None, + "sw_version": None, + "via_device_id": via, + } + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + _device( + "parent000000000000000000000000", + [entry_a.entry_id, entry_b.entry_id], + [["dom_a", "p"], ["dom_b", "p"]], + None, + ), + _device( + "child0000000000000000000000000", + [entry_a.entry_id], + [["dom_a", "c"]], + "parent000000000000000000000000", + ), + # child in a config entry the parent does not span + _device( + "childc000000000000000000000000", + [entry_c.entry_id], + [["dom_c", "c"]], + "parent000000000000000000000000", + ), + ], + "deleted_devices": [], }, } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - } - | extra_changes, + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The parent splits (fresh split ids, not the old composite id) + parent_a = registry.async_get_device(identifiers={("dom_a", "p")}) + parent_b = registry.async_get_device(identifiers={("dom_b", "p")}) + assert parent_a is not None + assert parent_b is not None + assert parent_a.config_entry_id == entry_a.entry_id + assert parent_a.id != "parent000000000000000000000000" + + # The child in entry_a points at the parent's entry_a split + child = registry.async_get_device(identifiers={("dom_a", "c")}) + assert child is not None + assert child.via_device_id == parent_a.id + + # The child in entry_c, which the parent did not span, points at one of the parent's + # splits rather than the removed composite id + child_c = registry.async_get_device(identifiers={("dom_c", "c")}) + assert child_c is not None + assert child_c.via_device_id in {parent_a.id, parent_b.id} + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.parametrize( + ("composite_disabled_by", "expected_split_enabled", "expected_split_disabled"), + [ + pytest.param( + None, None, dr.DeviceEntryDisabler.CONFIG_ENTRY, id="enabled_composite" + ), + pytest.param( + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + id="user_disabled", + ), + pytest.param( + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + id="config_entry_disabled", + ), + ], +) +async def test_migration_split_disabled_by_follows_config_entry( + hass: HomeAssistant, + hass_storage: dict[str, Any], + composite_disabled_by: dr.DeviceEntryDisabler | None, + expected_split_enabled: dr.DeviceEntryDisabler | None, + expected_split_disabled: dr.DeviceEntryDisabler, +) -> None: + """A split's disabled_by follows its single owning config entry's disabled state. + + A composite spanning an enabled and a disabled config entry copies its disabled_by to + both splits; each split is then reconciled against its own entry - the split owned by + the disabled entry becomes CONFIG_ENTRY disabled (a USER disable is preserved), while + the split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared. + """ + entry_enabled = MockConfigEntry(domain="dom_a") + entry_enabled.add_to_hass(hass) + entry_disabled = MockConfigEntry( + domain="dom_b", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry_disabled.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [ + entry_enabled.entry_id, + entry_disabled.entry_id, + ], + "config_entries_subentries": { + entry_enabled.entry_id: [None], + entry_disabled.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": composite_disabled_by, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_enabled.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + split_enabled = registry.async_get_device(identifiers={("dom_a", "x")}) + split_disabled = registry.async_get_device(identifiers={("dom_b", "x")}) + assert split_enabled is not None + assert split_disabled is not None + assert split_enabled.config_entry_id == entry_enabled.entry_id + assert split_disabled.config_entry_id == entry_disabled.entry_id + # The split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared + assert split_enabled.disabled_by is expected_split_enabled + # The split owned by the disabled entry follows that entry (USER preserved) + assert split_disabled.disabled_by is expected_split_disabled + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_disabled_by_not_reconciled_without_composite_split( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """disabled_by is reconciled only for split composites, not other migrated devices. + + A 1.12 -> 1.13 migration that splits no composite does not touch a device whose stored + disabled_by does not match its config entry. + """ + entry = MockConfigEntry( + domain="dom_a", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device000000000000000000000000", + "identifiers": [["dom_a", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + device = registry.async_get_device(identifiers={("dom_a", "x")}) + assert device is not None + # The reconcile is gated on a composite split, so disabled_by is left as stored + assert device.disabled_by is None + + +@pytest.mark.parametrize("config_entry_disabled", [False, True]) +@pytest.mark.parametrize( + "initial_disabled_by", + [ + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, + ], +) +async def test_migrate_device_disabled_by_matches_runtime( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + initial_disabled_by: dr.DeviceEntryDisabler | None, + config_entry_disabled: bool, +) -> None: + """The migration dict reconcile matches async_config_entry_disabled_by_changed. + + _migrate_device_disabled_by reimplements the runtime helper on stored data, so for + every combination of device disabled_by and config entry state both must agree. + """ + config_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + if config_entry_disabled + else None + ) + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "1")} + ) + # Explicit disabled_by bypasses async_update_device's own reconciliation + device_registry.async_update_device(device.id, disabled_by=initial_disabled_by) + + # Runtime helper on the loaded registry + dr.async_config_entry_disabled_by_changed(device_registry, config_entry) + runtime_result = device_registry.async_get(device.id).disabled_by + + # Migration helper on the stored representation + stored = {"disabled_by": initial_disabled_by} + dr._migrate_device_disabled_by(stored, config_entry_disabled) + + assert stored["disabled_by"] == runtime_result + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_composite_lineage_not_restored_after_remove( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A migrated split loses its composite lineage once removed. + + The deleted device does not carry composite data, so re-registering the split makes a + plain device that no longer resolves from the pre-migration composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + + old_id = "composite00000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": old_id, + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + split_a = registry.async_get_device(identifiers={("dom_a", "x")}) + assert split_a is not None + assert split_a.composite_device_id == old_id + + # Remove the split; the deleted device does not carry the composite lineage + registry.async_remove_device(split_a.id) + + # Re-registering reuses the deleted device's id but drops the composite lineage + restored = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("dom_a", "x")} + ) + assert restored.id == split_a.id + assert restored.composite_device_id is None + assert restored not in registry.async_get_devices_for_composite_device_id(old_id) async def test_cleanup_device_registry( @@ -3900,8 +5123,8 @@ async def test_restore_device( ) assert entry2 == dr.DeviceEntry( area_id=None, - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url=None, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, created_at=utcnow(), @@ -3917,7 +5140,6 @@ async def test_restore_device( modified_at=utcnow(), name_by_user=None, name=None, - primary_config_entry=entry_id, serial_number=None, sw_version=None, ) @@ -3942,8 +5164,8 @@ async def test_restore_device( ) assert entry3 == dr.DeviceEntry( area_id=initial_area, - config_entries={entry_id}, - config_entries_subentries={entry_id: {subentry_id}}, + config_entry_id=entry_id, + config_subentry_id=subentry_id, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -3959,7 +5181,6 @@ async def test_restore_device( modified_at=utcnow(), name_by_user="Test Friendly Name", name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4081,8 +5302,8 @@ async def test_restore_migrated_device_disabled_by( ) assert entry3 == dr.DeviceEntry( area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -4098,7 +5319,6 @@ async def test_restore_migrated_device_disabled_by( modified_at=utcnow(), name_by_user=None, name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4249,8 +5469,8 @@ async def test_restore_disabled_by( ) assert entry3 == dr.DeviceEntry( area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -4266,7 +5486,6 @@ async def test_restore_disabled_by( modified_at=utcnow(), name_by_user=None, name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4298,353 +5517,6 @@ async def test_restore_disabled_by( } -@pytest.mark.usefixtures("freezer") -async def test_restore_shared_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Make sure device id is stable for shared devices.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ), - ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_orig_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_orig_1", - model="model_orig_1", - model_id="model_id_orig_1", - name="name_orig_1", - serial_number="serial_no_orig_1", - suggested_area="suggested_area_orig_1", - sw_version="version_orig_1", - via_device="via_device_id_orig_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Add another config entry to the same device - device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_orig_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - name="name_orig_2", - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - via_device="via_device_id_orig_2", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Apply user customizations - updated_device = device_registry.async_update_device( - entry.id, - area_id="12345A", - disabled_by=dr.DeviceEntryDisabler.USER, - labels={"label1", "label2"}, - name_by_user="Test Friendly Name", - ) - - # Check device entry before we remove it - assert updated_device == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_orig_2", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_orig_2", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - ) - - device_registry.async_remove_device(entry.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - # config_entry_1 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry2 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id}, - config_entries_subentries={config_entry_1.entry_id: {"mock-subentry-id-1-1"}}, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry2.config_entries, set) - assert isinstance(entry2.connections, set) - assert isinstance(entry2.identifiers, set) - - # Remove the device again - device_registry.async_remove_device(entry.id) - - # config_entry_2 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_new_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - name="name_new_2", - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - via_device="via_device_id_new_2", - ) - - assert entry3 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_new_2", - id=entry.id, - identifiers={("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_2", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) - - # Add config_entry_1 back to the restored device - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry4 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry4.config_entries, set) - assert isinstance(entry4.connections, set) - assert isinstance(entry4.identifiers, set) - - await hass.async_block_till_done() - - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - "configuration_url": "http://config_url_orig_1.bla", - "entry_type": dr.DeviceEntryType.SERVICE, - "hw_version": "hw_version_orig_1", - "identifiers": {("entry_123", "0123")}, - "manufacturer": "manufacturer_orig_1", - "model": "model_orig_1", - "model_id": "model_id_orig_1", - "name": "name_orig_1", - "serial_number": "serial_no_orig_1", - "suggested_area": "suggested_area_orig_1", - "sw_version": "version_orig_1", - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "area_id": "suggested_area_orig_1", - "disabled_by": None, - "labels": set(), - "name_by_user": None, - }, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": updated_device.dict_repr, - } - assert update_events[4].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[5].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[6].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[7].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_2.entry_id}, - "config_entries_subentries": {config_entry_2.entry_id: {None}}, - "configuration_url": "http://config_url_new_2.bla", - "entry_type": None, - "hw_version": "hw_version_new_2", - "identifiers": {("entry_234", "2345")}, - "manufacturer": "manufacturer_new_2", - "model": "model_new_2", - "model_id": "model_id_new_2", - "name": "name_new_2", - "serial_number": "serial_no_new_2", - "suggested_area": "suggested_area_new_2", - "sw_version": "version_new_2", - }, - } - - async def test_get_or_create_empty_then_set_default_values( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, @@ -4823,50 +5695,6 @@ async def test_disable_config_entry_disables_devices( assert entry2.disabled_by is dr.DeviceEntryDisabler.USER -async def test_only_disable_device_if_all_config_entries_are_disabled( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Test that we only disable device if all related config entries are disabled.""" - config_entry1 = MockConfigEntry(domain="light") - config_entry1.add_to_hass(hass) - config_entry2 = MockConfigEntry(domain="light") - config_entry2.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=config_entry1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert len(entry1.config_entries) == 2 - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry1.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry2.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert entry1.disabled - assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - - await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - @pytest.mark.parametrize( ("configuration_url", "expectation"), [ @@ -4999,8 +5827,14 @@ async def test_loading_invalid_configuration_url_from_storage( "devices": [ { "area_id": None, - "config_entries": ["1234"], - "config_entries_subentries": {"1234": [None]}, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": "invalid", "connections": [], "created_at": "2024-01-01T00:00:00+00:00", @@ -5016,7 +5850,7 @@ async def test_loading_invalid_configuration_url_from_storage( "modified_at": "2024-02-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": "1234", + "primary_config_entry": mock_config_entry.entry_id, "serial_number": None, "sw_version": None, "via_device_id": None, @@ -5619,78 +6453,6 @@ async def test_device_registry_deleted_device_collision( assert len(device_registry.deleted_devices) == 0 -async def test_primary_config_entry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, -) -> None: - """Test the primary integration field.""" - mock_config_entry_1 = MockConfigEntry(domain="mqtt", title=None) - mock_config_entry_1.add_to_hass(hass) - mock_config_entry_2 = MockConfigEntry(title=None) - mock_config_entry_2.add_to_hass(hass) - mock_config_entry_3 = MockConfigEntry(title=None) - mock_config_entry_3.add_to_hass(hass) - mock_config_entry_4 = MockConfigEntry(domain="matter", title=None) - mock_config_entry_4.add_to_hass(hass) - - # Create device without model name etc, config entry will not be marked primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - ) - assert device.primary_config_entry is None - - # Set model, mqtt config entry will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model", - ) - assert device.primary_config_entry == mock_config_entry_1.entry_id - - # New config entry with model will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 2", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # New config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_3.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # New matter config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_4.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # Remove the primary config entry - device = device_registry.async_update_device( - device.id, - remove_config_entry_id=mock_config_entry_2.entry_id, - ) - assert device.primary_config_entry is None - - # Create new - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - manufacturer="manufacturer", - model="model", - ) - assert device.primary_config_entry == mock_config_entry_1.entry_id - - async def test_update_device_no_connections_or_identifiers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -5713,7 +6475,10 @@ async def test_update_device_no_connections_or_identifiers( async def test_connections_validator() -> None: """Test checking connections validator.""" with pytest.raises(ValueError, match="Invalid mac address format"): - dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) + dr.DeviceEntry( + config_entry_id="mock-config-entry", + connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}, + ) async def test_suggested_area_deprecation( @@ -5755,3 +6520,946 @@ async def test_suggested_area_deprecation( "device. This will stop working in Home Assistant 2026.9.0, please report " "this issue" ) in caplog.text + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +async def test_single_config_entry_and_compat_properties( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device has a single config entry; the deprecated shims reflect it.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id is None + assert device.config_entries == {entry.entry_id} + assert device.config_entries_subentries == {entry.entry_id: {None}} + assert device.primary_config_entry == entry.entry_id + + +async def test_identifiers_unique_per_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The same identifier under two config entries yields two devices.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("shared", "1")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("shared", "1")} + ) + + assert device_a.id != device_b.id + + # Scoped lookup returns the owning device + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("shared", "1")} + ).id + == device_a.id + ) + assert ( + _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("shared", "1")} + ).id + == device_b.id + ) + + +async def test_collision_only_within_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A collision is raised only for two devices of the same config entry.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + other = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "2")} + ) + + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + other.id, merge_identifiers={("domain_a", "1")} + ) + assert device_registry.async_get(device.id) is not None + + +async def test_remove_shadowed_collision_keeps_index_consistent( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Removing a device that shadows a same-entry collision keeps the index consistent. + + allow_collisions lets a device absorb an identifier another device of the same config + entry holds, shadowing it in the index. When a second config entry also shares that + identifier, removing the shadowed device then the indexed one must not delete the wrong + slot or raise KeyError on the mapping the second entry keeps. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "1")} + ) + shadowed = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "2")} + ) + # The second config entry keeps its own slot for the shared identifier + other_entry_device = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "2")} + ) + # allow_collisions lets `device` absorb the shadowed device's identifier + device_registry._async_update_device( + device.id, merge_identifiers={("test", "2")}, allow_collisions=True + ) + assert device_registry.async_get(device.id).identifiers == { + ("test", "1"), + ("test", "2"), + } + 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) + device_registry.async_remove_device(device.id) + + # The second config entry's device is still reachable by the shared identifier + assert ( + device_registry.async_get_device(identifiers={("test", "2")}) + is other_entry_device + ) + + +@pytest.mark.parametrize( + ("identity", "merge_kwarg", "merge_extra", "error"), + [ + pytest.param( + {"identifiers": {("test", "shared")}}, + "merge_identifiers", + {("test", "extra")}, + dr.DeviceIdentifierCollisionError, + id="identifiers", + ), + pytest.param( + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}}, + "merge_connections", + {(dr.CONNECTION_NETWORK_MAC, "ab:cd:ef:12:34:56")}, + dr.DeviceConnectionCollisionError, + id="connections", + ), + ], +) +async def test_move_with_merge_validates_retained_identity( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + identity: dict[str, set[tuple[str, str]]], + merge_kwarg: str, + merge_extra: set[tuple[str, str]], + error: type[Exception], +) -> None: + """A move that also merges must validate the retained identity against the target. + + The merged additions are validated, but the retained old identity must be too, or the + move silently overwrites the target entry's index slot for a device already there. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, **identity + ) + # entry_b already owns a device with the same identity + device_registry.async_get_or_create(config_entry_id=entry_b.entry_id, **identity) + + # Moving device to entry_b retains its identity, which collides with entry_b's + # existing device, so the move must raise rather than silently shadow it. + with pytest.raises(error): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + **{merge_kwarg: merge_extra}, + ) + + +async def test_move_two_calls_add_then_remove( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test add_config_entry_id records a pending move; the later remove performs it.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + # add alone does nothing yet + device_registry.async_update_device(device.id, add_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_a.entry_id + + # remove of the current owner performs the pending move + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + moved = device_registry.async_get(device.id) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + + +async def test_move_new_config_entry_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test new_config_entry_id moves the device immediately.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + device_registry.async_update_device(device.id, new_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_b.entry_id + + +async def test_move_new_and_add_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test mixing new_config_entry_id with add/remove raises.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + with pytest.raises(HomeAssistantError, match="Can't combine"): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + add_config_entry_id=entry_b.entry_id, + ) + + +async def test_async_get_or_create_unknown_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get_or_create raises for an unknown config entry.""" + with pytest.raises( + HomeAssistantError, + match="Can't link device to unknown config entry unknown-config-entry", + ): + device_registry.async_get_or_create( + config_entry_id="unknown-config-entry", identifiers={("bridgeid", "0123")} + ) + + +@pytest.mark.parametrize( + ("make_update_kwargs", "error_match"), + [ + pytest.param( + lambda entry: {"add_config_entry_id": "unknown-config-entry"}, + "Can't link device to unknown config entry unknown-config-entry", + id="add-unknown-config-entry", + ), + pytest.param( + lambda entry: {"add_config_subentry_id": "mock-subentry-id-2"}, + "Can't add config subentry without specifying config entry", + id="add-subentry-without-config-entry", + ), + pytest.param( + lambda entry: { + "add_config_entry_id": entry.entry_id, + "add_config_subentry_id": "unknown-subentry", + }, + "has no subentry unknown-subentry", + id="add-unknown-subentry", + ), + pytest.param( + lambda entry: {"remove_config_subentry_id": "mock-subentry-id-1"}, + "Can't remove config subentry without specifying config entry", + id="remove-subentry-without-config-entry", + ), + pytest.param( + lambda entry: {"new_config_entry_id": "unknown-config-entry"}, + "Can't move device to unknown config entry unknown-config-entry", + id="new-unknown-config-entry", + ), + pytest.param( + lambda entry: {"new_config_subentry_id": "unknown-subentry"}, + "has no subentry unknown-subentry", + id="new-unknown-subentry", + ), + pytest.param( + lambda entry: { + "new_config_entry_id": entry.entry_id, + "add_config_entry_id": entry.entry_id, + }, + "Can't combine new_config_entry_id or new_config_subentry_id", + id="combine-new-and-add", + ), + ], +) +async def test_update_device_config_entry_grammar_errors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + make_update_kwargs: Callable[[MockConfigEntry], dict[str, Any]], + error_match: str, +) -> None: + """The config-entry/subentry mutation grammar validates its arguments.""" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) + + with pytest.raises(HomeAssistantError, match=error_match): + device_registry.async_update_device(device.id, **make_update_kwargs(entry)) + + +async def test_move_device_to_config_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device can be moved to another subentry of its config entry. + + Immediately via new_config_subentry_id, or deferred via a pending move + (add_config_entry_id + add_config_subentry_id, completed by removing the current + owner). There is no subentry-only deferred move - add_config_subentry_id and + remove_config_subentry_id without a config entry raise (see + test_update_device_config_entry_grammar_errors). + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) + + # new_config_subentry_id moves the device immediately + moved = device_registry.async_update_device( + device.id, new_config_subentry_id="mock-subentry-id-2" + ) + assert moved.config_entry_id == entry.entry_id + assert moved.config_subentry_id == "mock-subentry-id-2" + + # Deferred move: adding the (same) config entry with the target subentry records a + # pending move; it does not move the device on its own + device_registry.async_update_device( + device.id, + add_config_entry_id=entry.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + assert ( + device_registry.async_get(device.id).config_subentry_id == "mock-subentry-id-2" + ) + # Removing the current owner performs the pending move to the target subentry + moved_back = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + assert moved_back is not None + assert moved_back.config_subentry_id == "mock-subentry-id-1" + + +async def test_move_device_to_config_entry_and_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A deferred move can target another config entry and one of its subentries.""" + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-b", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("bridgeid", "0123")} + ) + + # The pending move carries the (config entry, subentry) pair + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_b.entry_id, + add_config_subentry_id="mock-subentry-id-b", + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + assert moved.config_subentry_id == "mock-subentry-id-b" + + +async def test_pending_move_overwritten_by_later_add( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A later add_config_entry_id / add_config_subentry_id overwrites the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Each add records a pending move, overwriting the previous one: first a subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + # ... a later add to the same entry overwrites just the subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-2", + ) + # ... a later add to a different entry overwrites the entry (subentry resets to None) + device_registry.async_update_device(device.id, add_config_entry_id=entry_3.entry_id) + + # None of the adds moved the device + assert device_registry.async_get(device.id).config_entry_id == entry_1.entry_id + + # Removing the owner performs the last recorded pending move + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_3.entry_id + assert moved.config_subentry_id is None + + +async def test_new_config_entry_id_clears_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An immediate new_config_entry_id move clears an earlier pending move. + + Otherwise removing the new owner would perform the stale deferred move instead of + deleting the device, which has no other config entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Record a pending move to entry_2, then immediately move the device to entry_3 + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(device.id, new_config_entry_id=entry_3.entry_id) + assert device_registry.async_get(device.id)._pending_move is None + + # Removing the new owner deletes the device rather than performing the stale move + assert ( + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_3.entry_id + ) + is None + ) + assert device_registry.async_get(device.id) is None + + +async def test_pending_move_canceled_by_cross_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from a different integration than the one that armed the move cancels it. + + Otherwise an incidental add_config_entry_id (e.g. device_tracker attaching a shared + MAC) would hijack the owning integration's later cleanup and move the device instead + of deleting it. + """ + entry_owner = MockConfigEntry(domain="owner") + entry_owner.add_to_hass(hass) + entry_target = MockConfigEntry(domain="attacher") + entry_target.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_owner.entry_id, identifiers={("test", "1")} + ) + + # The "attacher" integration arms a deferred move to its own entry + with patch.object(dr, "_current_integration_domain", return_value="attacher"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_target.entry_id + ) + assert ( + device_registry.async_get(device.id)._pending_move.origin_domain == "attacher" + ) + + # The owning integration later removes its entry - a different domain, so the stale + # move is canceled and the device is deleted rather than transferred. + with patch.object(dr, "_current_integration_domain", return_value="owner"): + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_owner.entry_id + ) + assert result is None + assert device_registry.async_get(device.id) is None + + +async def test_pending_move_completed_by_same_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from the same integration that armed the move completes it.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + + with patch.object(dr, "_current_integration_domain", return_value="mover"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_2.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert device_registry.async_get(device.id) is moved + + +async def test_composite_move_clears_sibling_pending_moves( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Completing one split's move clears the pending move on its composite siblings. + + Arming add_config_entry_id on a composite fans out to every split; once one split + moves to the target, the others must not also move there and collide. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + entry_target = MockConfigEntry(domain="test") + entry_target.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + 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 + ) + + # Arm a deferred move on the composite id: fans out to both splits + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + old_id, add_config_entry_id=entry_target.entry_id + ) + assert device_registry.async_get(device_1.id)._pending_move is not None + assert device_registry.async_get(device_2.id)._pending_move is not None + + # Complete the move on split 1; split 2's pending move must be cleared + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + device_1.id, remove_config_entry_id=entry_1.entry_id + ) + assert ( + device_registry.async_get(device_1.id).config_entry_id == entry_target.entry_id + ) + assert device_registry.async_get(device_2.id)._pending_move is None + + # Split 2's own removal now deletes it instead of colliding on the shared identifier + with patch.object(dr, "_current_integration_domain", return_value="test"): + assert ( + device_registry.async_update_device( + device_2.id, remove_config_entry_id=entry_2.entry_id + ) + is None + ) + assert device_registry.async_get(device_2.id) is None + + +async def test_add_and_remove_config_entry_in_one_call( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """add_config_entry_id and remove_config_entry_id of the owner move in a single call.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Adding the new entry/subentry and removing the current owner in one call moves at once + moved = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + remove_config_entry_id=entry_1.entry_id, + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert moved.config_subentry_id == "mock-subentry-id-1" + + await hass.async_block_till_done() + assert len(update_events) == 2 + assert update_events[1].data == { + "action": "update", + "device_id": device.id, + "changes": { + "config_entry_id": entry_1.entry_id, + "config_subentry_id": None, + }, + } + + +async def test_remove_non_owner_config_entry_keeps_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """remove_config_entry_id of a non-owning entry does not perform the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Add a pending move to entry_2, but remove a config entry the device does not own + result = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + remove_config_entry_id=entry_3.entry_id, + ) + # The device is neither moved nor removed: only removing the owner performs the move + assert result is not None + assert result.config_entry_id == entry_1.entry_id + + # The pending move to entry_2 was still recorded; removing the owner now performs it + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_reregistration_replaces_composite_identifiers( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """First re-registration replaces the copied identifiers with the provided ones.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a.has_composite_identifiers is True + + reregistered = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert reregistered.id == split_a.id + assert reregistered.identifiers == {("domain_a", "1")} # domain_b copy pruned + # assert the copied composite connection is cleared + assert reregistered.connections == set() + assert reregistered.has_composite_identifiers is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_returns_restored_composite( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test async_get on the legacy id returns a merged, on-demand composite.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.id == COMPOSITE_ID + assert composite.config_entries == {entry_a.entry_id, entry_b.entry_id} + assert composite.config_entries_subentries == { + entry_a.entry_id: {None}, + entry_b.entry_id: {None}, + } + assert composite.identifiers == {("domain_a", "1"), ("domain_b", "1")} + 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 ( + device_registry.async_get_device(identifiers={("domain_a", "1")}).id + != COMPOSITE_ID + ) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_restored_composite_preserves_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The restored composite reports the pre-migration composite's former primary. + + The composite's primary_config_entry is recorded on each split device + (composite_primary_config_entry) so the restored composite can report it, even when + it is not the first split. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's primary is entry_b, which is not its first config entry + storage = _composite_device_storage(entry_a, entry_b) + storage["data"]["devices"][0]["primary_config_entry"] = entry_b.entry_id + hass_storage[dr.STORAGE_KEY] = storage + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + + # The former primary (entry_b) is preserved, even though it is not the first split + assert composite.primary_config_entry == entry_b.entry_id + assert composite.primary_config_entry != splits[0].config_entry_id + # It is a valid member of the merged config entries + assert composite.primary_config_entry in composite.config_entries + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_config_entry_clears_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing the composite's former primary config entry clears the dangling ref.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b.composite_primary_config_entry == entry_a.entry_id + + # Clearing entry_a removes its split and clears the reference on entry_b's split + device_registry.async_clear_config_entry(entry_a.entry_id) + + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + is None + ) + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b is not None + assert split_b.composite_primary_config_entry is None + + # The restored composite still works, falling back to the remaining split + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.primary_config_entry == entry_b.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_non_primary_config_entry_keeps_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing a non-primary config entry leaves composite_primary_config_entry intact.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + # Clearing entry_b (not the former primary) removes its split but keeps the reference + device_registry.async_clear_config_entry(entry_b.entry_id) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a is not None + assert split_a.composite_primary_config_entry == entry_a.entry_id + + +async def test_dict_repr_dual_writes_deprecated_keys( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test dict_repr exposes both the new and the deprecated compatibility keys.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + repr_ = device.dict_repr + assert repr_["config_entry_id"] == entry.entry_id + assert repr_["config_subentry_id"] is None + assert repr_["config_entries"] == [entry.entry_id] + assert repr_["config_entries_subentries"] == {entry.entry_id: [None]} + assert repr_["primary_config_entry"] == entry.entry_id + # Internal split-migration fields are not exposed in dict_repr + assert "composite_device_id" not in repr_ + assert "composite_primary_config_entry" not in repr_ + assert "split_at" not in repr_ + assert "has_composite_identifiers" not in repr_ diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index a24f7f4b994a..a2a0bc6ce628 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -554,6 +554,39 @@ async def test_entity_registry_loading_waits_for_device_registry( assert registry.async_get("test.my_entity") is not None +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_load_detaches_from_dropped_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity referencing a device that no longer exists is detached on load. + + The device migration drops a device with no config entry; an entity that pointed at + it must be detached rather than left on a removed device id. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.my_entity", + "device_id": "gone-device", + "platform": "test_platform", + "unique_id": "unique-1", + }, + ] + }, + } + + dr.async_setup(hass) + await asyncio.gather(er.async_load(hass), dr.async_load(hass)) + + registry = er.async_get(hass) + entity = registry.async_get("test.my_entity") + assert entity is not None + assert entity.device_id is None + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -1813,6 +1846,12 @@ async def test_migration_1_21( "area_id": None, "config_entries": ["mock_entry"], "config_entries_subentries": {"mock_entry": [None]}, + "config_entry_id": "mock_entry", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -2777,66 +2816,59 @@ async def test_remove_config_entry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config entry is removed.""" + """Test that we remove entities tied to a device when its config entry is removed.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + # Same connections on different config entries are separate devices + device_entry_1 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } + assert device_entry_1.id != device_entry_2.id - # Create one entity for each config entry + # Create one entity for each device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry_1, - device_id=device_entry.id, + device_id=device_entry_1.id, ) - entry_2 = entity_registry.async_get_or_create( "sensor", "device_tracker", "6789", config_entry=config_entry_2, - device_id=device_entry.id, + device_id=device_entry_2.id, ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device, the entity associated with it - # should be removed + # Removing the first config entry removes its device and the tied entity device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_1.entry_id + device_entry_1.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) + assert not device_registry.async_get(device_entry_1.id) assert not entity_registry.async_is_registered(entry_1.entity_id) + assert device_registry.async_get(device_entry_2.id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the second config entry from the device, the entity associated with it - # (and the device itself) should be removed + # Removing the second config entry removes its device and entity too device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry_2.id, remove_config_entry_id=config_entry_2.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not device_registry.async_get(device_entry_2.id) assert not entity_registry.async_is_registered(entry_2.entity_id) @@ -2845,72 +2877,148 @@ async def test_remove_config_entry_from_device_removes_entities_2( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config entry.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry(domain="device_tracker") + config_entry_2 = MockConfigEntry(domain="some_helper") config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry(domain="some_helper") - config_entry_3.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } - # Create an entity without config entry + # An entity without a config entry, tied to the device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity with a config entry not in the device + # An entity with a different config entry, tied to the device entry_2 = entity_registry.async_get_or_create( "light", "some_helper", "5678", - config_entry=config_entry_3, + config_entry=config_entry_2, device_id=device_entry.id, ) - assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device + # Removing the device's config entry removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - # Entities which are not tied to the removed config entry should not be removed + assert not device_registry.async_get(device_entry.id) + # Entities not tied to the removed config entry are kept, but detached assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None - # Remove the second config entry from the device (this removes the device) + +async def test_move_device_config_entry_removes_old_entry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Moving a device to another config entry removes the old entry's entities.""" + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="tado") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="some_helper") + entry_c.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("hue", "1")} + ) + # An entity owned by the departing entry A, and a helper entity of a third entry C + entry_a_entity = entity_registry.async_get_or_create( + "light", "hue", "a", config_entry=entry_a, device_id=device_entry.id + ) + entry_c_entity = entity_registry.async_get_or_create( + "sensor", "some_helper", "c", config_entry=entry_c, device_id=device_entry.id + ) + + # Move the device from entry A to entry B (an update, not a removal) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry.id, new_config_entry_id=entry_b.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - # Entities which are not tied to a config entry in the device should not be removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - # Check the device link is set to None - assert entity_registry.async_get(entry_1.entity_id).device_id is None - assert entity_registry.async_get(entry_2.entity_id).device_id is None + # A no longer owns the device, so A's entity is removed; C's helper is untouched + assert not entity_registry.async_is_registered(entry_a_entity.entity_id) + assert entity_registry.async_is_registered(entry_c_entity.entity_id) + + +@pytest.mark.parametrize("old_subentry_id", [None, "sub-1"]) +async def test_move_device_config_subentry_removes_old_subentry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + old_subentry_id: str | None, +) -> None: + """Moving a device to another subentry removes the old subentry's entities. + + Includes a departing subentry of None (the main entry): the change is detected by the + old config_subentry_id being present in the event, not by its truthiness. + """ + config_entry = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=old_subentry_id, + identifiers={("hue", "1")}, + ) + # Entity on the departing subentry, and one on the destination subentry sub-2 + old_entity = entity_registry.async_get_or_create( + "light", + "hue", + "old", + config_entry=config_entry, + config_subentry_id=old_subentry_id, + device_id=device_entry.id, + ) + sub2_entity = entity_registry.async_get_or_create( + "light", + "hue", + "2", + config_entry=config_entry, + config_subentry_id="sub-2", + device_id=device_entry.id, + ) + + # Move the device to subentry sub-2 (an update, not a removal) + device_registry.async_update_device(device_entry.id, new_config_subentry_id="sub-2") + await hass.async_block_till_done() + + # The departing subentry's entity is removed; sub-2's entity is kept + assert not entity_registry.async_is_registered(old_entity.entity_id) + assert entity_registry.async_is_registered(sub2_entity.entity_id) async def test_remove_config_subentry_from_device_removes_entities( @@ -2918,7 +3026,7 @@ async def test_remove_config_subentry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config subentry is removed.""" + """Test that we remove entities tied to a device when its config subentry is removed.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -2940,27 +3048,15 @@ async def test_remove_config_subentry_from_device_removes_entities( ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries - device_registry.async_get_or_create( + # A device belongs to a single config subentry + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, - } + assert device_entry.config_subentry_id == "mock-subentry-id-1" - # Create one entity entry for each config entry or subentry + # Entity tied to the device's subentry entry_1 = entity_registry.async_get_or_create( "light", "hue", @@ -2969,7 +3065,7 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-1", device_id=device_entry.id, ) - + # Entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", "hue", @@ -2978,22 +3074,11 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - entry_3 = entity_registry.async_get_or_create( - "sensor", - "device_tracker", - "6789", - config_entry=config_entry_1, - config_subentry_id=None, - device_id=device_entry.id, - ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device, the entity associated with it - # should be removed + # Removing the device's config subentry deletes the device; the entity tied to that + # subentry is removed, the entity tied to another subentry is detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, @@ -3001,55 +3086,18 @@ async def test_remove_config_subentry_from_device_removes_entities( ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, the entity associated with it - # should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the third config subentry from the device, the entity associated with it - # (and the device itself) should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-2", - ) - await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_2.entity_id).device_id is None -@pytest.mark.parametrize( - ("subentries_in_device", "subentry_in_entity"), - [ - (["mock-subentry-id-1", "mock-subentry-id-2"], None), - ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), - ], -) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - subentries_in_device: list[str | None], - subentry_in_entity: str | None, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config subentry.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -3067,95 +3115,49 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-3", - subentry_type="test", - title="Mock title", - unique_id="test", - ), ], ) config_entry_1.add_to_hass(hass) - # Create device with two config subentries - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[0], - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[1], + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: set(subentries_in_device), - } - # Create an entity without config entry or subentry + # An entity without a config entry entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity for same config entry but subentry not in device + # An entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", - "some_helper", - "5678", - config_entry=config_entry_1, - config_subentry_id=subentry_in_entity, - device_id=device_entry.id, - ) - # Create an entity for same config entry but subentry not in device - entry_3 = entity_registry.async_get_or_create( - "light", - "some_helper", + "hue", "abcd", config_entry=config_entry_1, - config_subentry_id="mock-subentry-id-3", + config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device + # Removing the device's config subentry deletes the device; entities not tied to + # that subentry are kept but detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[0], - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, this removes the device - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[1], + remove_config_subentry_id="mock-subentry-id-1", ) await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Check the device link is set to None assert entity_registry.async_get(entry_1.entity_id).device_id is None assert entity_registry.async_get(entry_2.entity_id).device_id is None - assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( @@ -3642,9 +3644,9 @@ async def test_resolve_entity_ids(entity_registry: er.EntityRegistry) -> None: er.async_validate_entity_ids(entity_registry, ["unknown_uuid"]) -def test_entity_registry_items() -> None: +async def test_entity_registry_items(hass: HomeAssistant) -> None: """Test the EntityRegistryItems container.""" - entities = er.EntityRegistryItems() + entities = er.EntityRegistryItems(hass) assert entities.get_entity_id(("a", "b", "c")) is None assert entities.get_entry("abc") is None @@ -3703,6 +3705,47 @@ async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None entity_registry.async_update_entity(entity_id, device_id="blah") +async def test_composite_device_id_not_allowed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an entity cannot be linked to a pre-migration composite device id. + + async_get resolves a composite id to a synthesized read-only device, but it is not a + real device, so linking an entity to it must be rejected. + """ + 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")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + 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 + ) + # 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 + + match = f"Device {old_id} does not exist" + with pytest.raises(ValueError, match=match): + entity_registry.async_get_or_create("light", "hue", "1234", device_id=old_id) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError, match=match): + entity_registry.async_update_entity(entity_id, device_id=old_id) + + async def test_disabled_by_str_not_allowed(entity_registry: er.EntityRegistry) -> None: """Test we need to pass disabled by type.""" with pytest.raises(ValueError): @@ -5406,3 +5449,293 @@ async def test_subentry( config_subentry_id="mock-subentry-id-2-1", ) assert entry.config_subentry_id == "mock-subentry-id-2-1" + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Entities are moved to the split device matching their config entry.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + assert entity_registry.async_get("sensor.a").device_id == by_entry[entry_a.entry_id] + assert entity_registry.async_get("sensor.b").device_id == by_entry[entry_b.entry_id] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities_fallbacks( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity not exactly matching a split falls back by config entry, then detaches.""" + entry_a = MockConfigEntry( + domain="domain_a", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-sub", + subentry_type="test", + title="t", + unique_id="u", + ) + ], + ) + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The split for entry_a is on the "mock-sub" subentry + device_store = _composite_device_storage(entry_a, entry_b) + device_store["data"]["devices"][0]["config_entries_subentries"] = { + entry_a.entry_id: ["mock-sub"], + entry_b.entry_id: [None], + } + hass_storage[dr.STORAGE_KEY] = device_store + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + # config entry matches a split, but the subentry does not + "entity_id": "sensor.sub", + "platform": "domain_a", + "unique_id": "sub", + "config_entry_id": entry_a.entry_id, + "config_subentry_id": None, + "device_id": COMPOSITE_ID, + }, + { + # no split matches the config entry (it has none) + "entity_id": "sensor.none", + "platform": "domain_a", + "unique_id": "none", + "config_entry_id": None, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + by_entry = {d.config_entry_id: d.id for d in splits} + # Subentry mismatch falls back to the split owning the entity's config entry + assert ( + entity_registry.async_get("sensor.sub").device_id == by_entry[entry_a.entry_id] + ) + # No split matches the config entry, so the entity is detached + assert entity_registry.async_get("sensor.none").device_id is None + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_entries_for_device_legacy_composite_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A legacy composite device id resolves to its split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + 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 + + # get_entries_for_device_id resolves the composite id to the split entities + assert { + entry.entity_id + for entry in entity_registry.entities.get_entries_for_device_id(COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # The public helper resolves the composite id via the device registry + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # Disabled entities are only included when requested, across the split devices + entity_registry.async_update_entity( + "sensor.b", disabled_by=er.RegistryEntryDisabler.USER + ) + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a"} + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, COMPOSITE_ID, include_disabled_entities=True + ) + } == {"sensor.a", "sensor.b"} + + # A live split device id returns just its own entity + splits = { + device.config_entry_id: device.id + for device in device_registry.async_get_devices_for_composite_device_id( + COMPOSITE_ID + ) + } + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, splits[entry_a.entry_id] + ) + } == {"sensor.a"} + + +async def test_async_entries_for_device_composite_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A pre-migration composite id resolves to the underlying devices' entities. + + Backwards compatibility for unmodified integrations: before the single-config-entry + rewrite a shared identifier resolved to one multi-config-entry device, so + async_entries_for_device(composite_id) returned all of that device's entities. After + the split, the composite's virtual id must resolve to the same union so a legacy + reference keeps working. + """ + 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")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + entity_1 = entity_registry.async_get_or_create( + "sensor", "itg1", "u1", config_entry=entry_1, device_id=device_1.id + ) + entity_2 = entity_registry.async_get_or_create( + "sensor", "itg2", "u2", config_entry=entry_2, device_id=device_2.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_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + assert old_id not in device_registry.devices + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, old_id) + } == {entity_1.entity_id, entity_2.entity_id} diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 640b2ff011af..77b6ae2d2dfb 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -1,7 +1,7 @@ """Tests for the helper entity helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -213,6 +213,67 @@ def listen_entity_registry_events( return events +@pytest.mark.parametrize("add_helper_config_entry_to_device", [True, False]) +async def test_async_handle_source_entity_changes_deprecated_kwarg( + hass: HomeAssistant, + add_helper_config_entry_to_device: bool, +) -> None: + """The removed add_helper_config_entry_to_device kwarg is accepted but reported. + + It is swallowed by **kwargs so callers still passing it don't raise, and reported on + its presence rather than its value, since it no longer has any effect either way. + """ + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + add_helper_config_entry_to_device=add_helper_config_entry_to_device, + ) + unsub() + + report_usage.assert_called_once() + assert "add_helper_config_entry_to_device" in report_usage.call_args[0][0] + + +async def test_async_handle_source_entity_changes_rejects_unknown_kwarg( + hass: HomeAssistant, +) -> None: + """An unknown keyword argument still raises, as it did before **kwargs was added. + + **kwargs only exists to swallow the deprecated add_helper_config_entry_to_device; + anything else (e.g. a misspelling) must not be silently accepted. + """ + with pytest.raises(TypeError, match="unexpected keyword arguments 'unknown_kwarg'"): + async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + unknown_kwarg=True, + ) + + +async def test_async_handle_source_entity_changes_without_deprecated_kwarg( + hass: HomeAssistant, +) -> None: + """Not passing the removed add_helper_config_entry_to_device kwarg is not reported.""" + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + ) + unsub() + + report_usage.assert_not_called() + + @pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") @@ -230,33 +291,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( set_source_entity_id_or_uuid: Mock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -267,10 +312,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -294,34 +335,18 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: - """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source_entity_removed handler is called when the source entity is removed.""" assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -331,9 +356,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the custom handler took over: the helper entity is left linked to the + # source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -357,21 +383,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity removed from the source device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Remove the source entity from the device @@ -381,9 +399,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -408,11 +426,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity is moved to another device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Create another device to move the source entity to source_device_2 = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, @@ -422,15 +435,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id not in source_device_2.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Move the source entity to another device @@ -442,11 +450,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is moved to the other device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id in source_device_2.config_entries + # Check that the helper entity is relinked to the other device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device_2.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -475,21 +481,13 @@ async def test_async_handle_source_entity_new_entity_id( set_source_entity_id_calls: int, ) -> None: """Test the source entity's entity ID is changed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Change the source entity's entity ID @@ -501,9 +499,9 @@ async def test_async_handle_source_entity_new_entity_id( assert len(async_unload_entry.mock_calls) == unload_calls assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls - # Check that the helper config is still in the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the helper entity is still linked to the source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -520,13 +518,25 @@ async def test_async_remove_helper_config_entry_from_source_device( entity_registry: er.EntityRegistry, helper_config_entry: MockConfigEntry, helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, source_device: dr.DeviceEntry, ) -> None: """Test removing the helper config entry from the source device.""" - # Add the helper config entry to the source device + # In the single-owner model the migration helper only acts when the helper config + # entry owns the source device. Move the source device to the helper config entry + # and record a pending move back to the source config entry, so removing the helper + # config entry hands the device back to the source config entry instead of deleting + # it. device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id + source_device.id, + add_config_entry_id=helper_config_entry.entry_id, + remove_config_entry_id=source_config_entry.entry_id, ) + device_registry.async_update_device( + source_device.id, add_config_entry_id=source_config_entry.entry_id + ) + source_device = device_registry.async_get(source_device.id) + assert source_device.config_entries == {helper_config_entry.entry_id} # Create a helper entity entry, not connected to the source device extra_helper_entity_entry = entity_registry.async_get_or_create( diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 95bbaaad87fc..9d8a2e764487 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -300,6 +300,38 @@ def test_device_selector_schema_error(schema) -> None: ( { "filter": [ + { + "device": { + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + } + } + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "domain": "binary_sensor", + "device": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + }, + }, + { + "device": { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + "model_id": "other-mock-model_id", + }, + }, {"unit_of_measurement": "baguette"}, ] }, @@ -341,6 +373,10 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"unit_of_measurement": ["currywurst", "bratwurst"]}, # Invalid unit_of_measurement {"filter": [{"unit_of_measurement": 42}]}, + # Device properties must be grouped under the device key + {"filter": [{"manufacturer": "mock-manuf"}]}, + {"filter": [{"model": "mock-model"}]}, + {"filter": [{"model_id": "mock-model_id"}]}, # reorder can only be used when multiple is true {"reorder": True}, {"reorder": True, "multiple": False}, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 29c31d494777..e9e459a4d60a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -163,10 +163,18 @@ def floor_area_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) mock_device_registry( hass, @@ -330,13 +338,21 @@ def label_mock(hass: HomeAssistant) -> None: }, ) - device_has_label1 = dr.DeviceEntry(labels={"label1"}) - device_has_label2 = dr.DeviceEntry(labels={"label2"}) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label1"} + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label2"} + ) device_has_labels = dr.DeviceEntry( - labels={"label1", "label2"}, area_id=area_with_labels.id + config_entry_id="mock-config-entry", + labels={"label1", "label2"}, + area_id=area_with_labels.id, ) device_no_labels = dr.DeviceEntry( - id="device-no-labels", area_id=area_without_labels.id + config_entry_id="mock-config-entry", + id="device-no-labels", + area_id=area_without_labels.id, ) mock_device_registry( @@ -2491,7 +2507,10 @@ async def test_async_extract_entities_warn_referenced( async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: """Test we can find devices that have no entities.""" - device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) + device_no_entities = dr.DeviceEntry( + config_entry_id="abc", + id="device-no-entities", + ) call = ServiceCall( hass, diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 9d72951868ed..93b9adf7a67d 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Mapping +from typing import Any import pytest @@ -109,13 +110,30 @@ def registries_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") - device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"}) - device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"}) + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-test-area", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-diff-area", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label1-id", + labels={"label1"}, + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label2-id", + labels={"label2"}, + ) device_has_labels = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-has-labels-id", labels={"label1", "label2"}, area_id=area_with_labels.id, @@ -988,3 +1006,94 @@ async def test_async_track_target_selector_no_on_entities_update( assert len(events) == 1 unsub() + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_target_trickle_down_to_splits( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Targeting the legacy id reaches the split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": COMPOSITE_ID}) + ) + assert COMPOSITE_ID not in selected.missing_devices + splits = { + d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + # The composite id resolves to its splits only; it is not itself referenced (it is not + # a real device), so a device-id consumer does not act on the same device twice. + assert selected.referenced_devices == splits + assert COMPOSITE_ID not in selected.referenced_devices + assert selected.indirectly_referenced == {"sensor.a", "sensor.b"} diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 561c4a060845..9b296dffe0df 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -100,6 +100,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', @@ -209,6 +210,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', diff --git a/tests/syrupy.py b/tests/syrupy.py index a87799631098..aa1b5446e942 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -3,22 +3,14 @@ from contextlib import suppress import dataclasses from enum import IntFlag -import json -import os from pathlib import Path from typing import Any import attr import attrs -import pytest -from syrupy.constants import EXIT_STATUS_FAIL_UNUSED -from syrupy.data import Snapshot, SnapshotCollection, SnapshotCollections from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension from syrupy.location import PyTestLocation -from syrupy.report import SnapshotReport -from syrupy.session import ItemStatus, SnapshotSession from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData -from syrupy.utils import is_xdist_controller, is_xdist_worker import voluptuous as vol import voluptuous_serialize @@ -44,6 +36,17 @@ ANY = _ANY() __all__ = ["HomeAssistantSnapshotExtension"] +# DeviceEntry attributes that are internal bookkeeping and should not appear in snapshots. +# Underscore attributes (_cache, _suggested_area and the transient _pending_move / +# _composite_subentries) are excluded separately. The composite-device migration +# attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + class AreaRegistryEntrySnapshot(dict): """Tiny wrapper to represent an area registry entry in snapshots.""" @@ -158,21 +161,25 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): cls, data: dr.DeviceEntry ) -> SerializableData: """Prepare a Home Assistant device registry entry for serialization.""" + # Exclude internal attributes (caches, transient move state, and the + # composite-device migration bookkeeping) from the snapshot serialized = DeviceRegistryEntrySnapshot( - attrs.asdict(data) - | { - "config_entries": ANY, - "config_entries_subentries": ANY, - "id": ANY, - } + attr.asdict( + data, + retain_collection_types=True, + filter=lambda attribute, _: ( + not attribute.name.startswith("_") + and attribute.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ), + ) + | {"id": ANY} ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - if serialized["primary_config_entry"] is not None: - serialized["primary_config_entry"] = ANY - serialized.pop("_cache") - # This can be removed when suggested_area is removed from DeviceEntry - serialized.pop("_suggested_area") + + serialized["config_entry_id"] = ANY + serialized["config_subentry_id"] = ANY + return cls._remove_created_and_modified_at(serialized) @classmethod @@ -272,164 +279,3 @@ class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """ test_dir = Path(test_location.filepath).parent return str(test_dir.joinpath("snapshots")) - - -# Classes and Methods to override default finish behavior in syrupy -# This is needed to handle the xdist plugin in pytest -# The default implementation does not handle the xdist plugin -# and will not work correctly when running tests in parallel -# with pytest-xdist. -# Temporary workaround until it is finalised inside syrupy -# See https://github.com/syrupy-project/syrupy/pull/901 - - -class _FakePytestObject: - """Fake object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake object.""" - self.__module__ = collected_item["modulename"] - self.__name__ = collected_item["methodname"] - - -class _FakePytestItem: - """Fake pytest.Item object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake pytest.Item object.""" - self.nodeid = collected_item["nodeid"] - self.name = collected_item["name"] - self.path = Path(collected_item["path"]) - self.obj = _FakePytestObject(collected_item) - - -def _serialize_collections(collections: SnapshotCollections) -> dict[str, Any]: - return { - k: [c.name for c in v] for k, v in collections._snapshot_collections.items() - } - - -def _serialize_report( - report: SnapshotReport, - collected_items: set[pytest.Item], - selected_items: dict[str, ItemStatus], -) -> dict[str, Any]: - return { - "discovered": _serialize_collections(report.discovered), - "created": _serialize_collections(report.created), - "failed": _serialize_collections(report.failed), - "matched": _serialize_collections(report.matched), - "updated": _serialize_collections(report.updated), - "used": _serialize_collections(report.used), - "_collected_items": [ - { - "nodeid": c.nodeid, - "name": c.name, - "path": str(c.path), - "modulename": c.obj.__module__, - "methodname": c.obj.__name__, - } - for c in list(collected_items) - ], - "_selected_items": { - key: status.value for key, status in selected_items.items() - }, - } - - -def _merge_serialized_collections( - collections: SnapshotCollections, json_data: dict[str, list[str]] -) -> None: - if not json_data: - return - for location, names in json_data.items(): - snapshot_collection = SnapshotCollection(location=location) - for name in names: - snapshot_collection.add(Snapshot(name)) - collections.update(snapshot_collection) - - -def _merge_serialized_report(report: SnapshotReport, json_data: dict[str, Any]) -> None: - _merge_serialized_collections(report.discovered, json_data["discovered"]) - _merge_serialized_collections(report.created, json_data["created"]) - _merge_serialized_collections(report.failed, json_data["failed"]) - _merge_serialized_collections(report.matched, json_data["matched"]) - _merge_serialized_collections(report.updated, json_data["updated"]) - _merge_serialized_collections(report.used, json_data["used"]) - for collected_item in json_data["_collected_items"]: - custom_item = _FakePytestItem(collected_item) - if not any( - t.nodeid == custom_item.nodeid and t.name == custom_item.nodeid - for t in report.collected_items - ): - report.collected_items.add(custom_item) - for key, selected_item in json_data["_selected_items"].items(): - if key in report.selected_items: - status = ItemStatus(selected_item) - if status is not ItemStatus.NOT_RUN: - report.selected_items[key] = status - else: - report.selected_items[key] = ItemStatus(selected_item) - - -def override_syrupy_finish(self: SnapshotSession) -> int: - """Override the finish method to allow for custom handling.""" - exitstatus = 0 - self.flush_snapshot_write_queue() - self.report = SnapshotReport( - base_dir=self.pytest_session.config.rootpath, - collected_items=self._collected_items, - selected_items=self._selected_items, - assertions=self._assertions, - options=self.pytest_session.config.option, - ) - - needs_xdist_merge = self.update_snapshots or bool( - self.pytest_session.config.option.include_snapshot_details - ) - - if is_xdist_worker(): - if not needs_xdist_merge: - return exitstatus - with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: - f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) - with open( - f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", - "w", - encoding="utf-8", - ) as f: - json.dump( - _serialize_report( - self.report, self._collected_items, self._selected_items - ), - f, - indent=2, - ) - return exitstatus - if is_xdist_controller(): - return exitstatus - - if needs_xdist_merge: - worker_count = None - try: - with open(".pytest_syrupy_worker_count", encoding="utf-8") as f: - worker_count = f.read() - os.remove(".pytest_syrupy_worker_count") - except FileNotFoundError: - pass - - if worker_count: - for i in range(int(worker_count)): - with open(f".pytest_syrupy_gw{i}_result", encoding="utf-8") as f: - _merge_serialized_report(self.report, json.load(f)) - os.remove(f".pytest_syrupy_gw{i}_result") - - if self.report.num_unused: - if self.update_snapshots: - self.remove_unused_snapshots( - unused_snapshot_collections=self.report.unused, - used_snapshot_collections=self.report.used, - ) - elif not self.warn_unused_snapshots: - exitstatus |= EXIT_STATUS_FAIL_UNUSED - return exitstatus diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index bc0ced1b5c3b..68c434592c32 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -619,9 +619,11 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert "recorder" in hass.config.components assert "http" in hass.config.components - assert order == [ - "http", - "an_after_dep", + # http (a dependency) and an_after_dep (an after_dependency) are both set + # up in the frontend substage of stage 0; their relative order depends on + # set iteration order and is not guaranteed. + assert set(order[:2]) == {"http", "an_after_dep"} + assert order[2:] == [ "frontend", "recorder", "normal_integration", diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 62ebc4de916f..d2b672d500be 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6260,6 +6260,57 @@ async def test_loading_old_data( assert entry.pref_disable_new_entities is True +async def test_async_initialize_sets_event_with_empty_store( + hass: HomeAssistant, +) -> None: + """The initialized event is set when there is no stored data to load. + + The device registry waits on this event during its own load. + """ + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + with patch.object(manager._store, "async_load", return_value=None): + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert manager.async_entries() == [] + + +async def test_async_initialize_sets_event_with_existing_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The initialized event is set when loading an existing store. + + The device registry waits on this event during its own load. + """ + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "data": { + "entries": [ + { + "version": 5, + "domain": "my_domain", + "entry_id": "mock-id", + "data": {"my": "data"}, + "source": "user", + "title": "Mock title", + "system_options": {"disable_new_entities": True}, + } + ] + }, + } + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert len(manager.async_entries()) == 1 + + async def test_deprecated_disabled_by_str_ctor() -> None: """Test deprecated str disabled_by constructor enumizes and logs a warning.""" with pytest.raises(