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':