diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml
index 487c22f26ef3bb..59fdf4576b6216 100644
--- a/.github/workflows/check-requirements.lock.yml
+++ b/.github/workflows/check-requirements.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"fb27b6cf5e2bd541c2618768a7ae632801335f0bea4ab437db52383209852b0a","compiler_version":"v0.68.3","strict":true,"agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"70aa938926d4aac250b6d7aca7251f663476fc2da39a29d1ffd569dc725c133a","compiler_version":"v0.68.3","strict":true,"agent_id":"copilot"}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.68.3","version":"v0.68.3"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.20"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.20"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.20"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.2.19"},{"image":"ghcr.io/github/github-mcp-server:v0.32.0"},{"image":"node:lts-alpine"}]}
# ___ _ _
# / _ \ | | (_)
@@ -48,6 +48,8 @@
name: "Requirements License and Availability Check"
"on":
pull_request:
+ # forks: # Fork filtering applied via job conditions
+ # - "*" # Fork filtering applied via job conditions
paths:
- requirements*.txt
- homeassistant/package_constraints.txt
@@ -56,6 +58,7 @@ name: "Requirements License and Availability Check"
- opened
- synchronize
- reopened
+ # roles: all # Roles processed as role check in pre-activation job
workflow_dispatch:
inputs:
aw_context:
@@ -78,9 +81,6 @@ run-name: "Requirements License and Availability Check"
jobs:
activation:
- needs: pre_activation
- if: >
- needs.pre_activation.outputs.activated == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id)
runs-on: ubuntu-slim
permissions:
actions: read
@@ -103,7 +103,6 @@ jobs:
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
- trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
- name: Generate agentic run info
id: generate_aw_info
env:
@@ -191,14 +190,14 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_dd6a62399847cc3f_EOF'
+ cat << 'GH_AW_PROMPT_f9148b37e60758ba_EOF'
- GH_AW_PROMPT_dd6a62399847cc3f_EOF
+ GH_AW_PROMPT_f9148b37e60758ba_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_dd6a62399847cc3f_EOF'
+ cat << 'GH_AW_PROMPT_f9148b37e60758ba_EOF'
Tools: add_comment, missing_tool, missing_data, noop
@@ -230,12 +229,12 @@ jobs:
{{/if}}
- GH_AW_PROMPT_dd6a62399847cc3f_EOF
+ GH_AW_PROMPT_f9148b37e60758ba_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_dd6a62399847cc3f_EOF'
+ cat << 'GH_AW_PROMPT_f9148b37e60758ba_EOF'
{{#runtime-import .github/workflows/check-requirements.md}}
- GH_AW_PROMPT_dd6a62399847cc3f_EOF
+ GH_AW_PROMPT_f9148b37e60758ba_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
@@ -259,7 +258,6 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -278,8 +276,7 @@ jobs:
GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
}
});
- name: Validate prompt placeholders
@@ -406,9 +403,9 @@ jobs:
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_70d1b46c67fbd74e_EOF'
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_e3fcd372e2542806_EOF'
{"add_comment":{"max":1},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_70d1b46c67fbd74e_EOF
+ GH_AW_SAFE_OUTPUTS_CONFIG_e3fcd372e2542806_EOF
- name: Write Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -592,7 +589,7 @@ jobs:
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.19'
mkdir -p /home/runner/.copilot
- cat << GH_AW_MCP_CONFIG_1873fcd645cdd82e_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh"
+ cat << GH_AW_MCP_CONFIG_710f47825b96ccb9_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh"
{
"mcpServers": {
"github": {
@@ -633,7 +630,7 @@ jobs:
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
}
}
- GH_AW_MCP_CONFIG_1873fcd645cdd82e_EOF
+ GH_AW_MCP_CONFIG_710f47825b96ccb9_EOF
- name: Download activation artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -1114,33 +1111,6 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs');
await main();
- pre_activation:
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id
- runs-on: ubuntu-slim
- outputs:
- activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
- matched_command: ''
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@v0.68.3
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- - name: Check team membership for workflow
- id: check_membership
- uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
- env:
- GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
- await main();
-
safe_outputs:
needs:
- activation
diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md
index 97a32cdff29bf1..12266c354b7a9b 100644
--- a/.github/workflows/check-requirements.md
+++ b/.github/workflows/check-requirements.md
@@ -6,12 +6,14 @@ on:
- "requirements*.txt"
- "homeassistant/package_constraints.txt"
- "pyproject.toml"
+ forks: ["*"]
workflow_dispatch:
inputs:
pull_request_number:
description: "Pull request number to (re-)check"
required: true
type: number
+ roles: all
permissions:
contents: read
pull-requests: read
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4beecc2c392904..81465b5d95f4b2 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.12
+ rev: v0.15.13
hooks:
- id: ruff-check
args:
diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py
index ca72f7aa7e6847..42b9812f90ba06 100644
--- a/homeassistant/components/avea/light.py
+++ b/homeassistant/components/avea/light.py
@@ -32,6 +32,7 @@
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
BREAKS_IN_HA_VERSION = "2026.12.0"
+AVEA_MAX_BRIGHTNESS = 4095
def _normalize_name(name: str | None) -> str | None:
@@ -41,6 +42,16 @@ def _normalize_name(name: str | None) -> str | None:
return name
+def _ha_brightness_to_avea(brightness: int) -> int:
+ """Convert Home Assistant brightness to Avea brightness."""
+ return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
+
+
+def _avea_brightness_to_ha(brightness: int) -> int:
+ """Convert Avea brightness to Home Assistant brightness."""
+ return round(255 * (brightness / AVEA_MAX_BRIGHTNESS))
+
+
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
"""Create the deprecated YAML issue for Avea."""
ir.async_create_issue(
@@ -176,21 +187,26 @@ def __init__(self, light: avea.Bulb, entry_title: str) -> None:
self._light = light
self._attr_name = entry_title
self._attr_brightness = light.brightness
+ self._last_brightness = 255
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if not kwargs:
- self._light.set_brightness(4095)
+ self._light.set_brightness(_ha_brightness_to_avea(self._last_brightness))
else:
if ATTR_BRIGHTNESS in kwargs:
- bright = round((kwargs[ATTR_BRIGHTNESS] / 255) * 4095)
- self._light.set_brightness(bright)
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ if brightness:
+ self._last_brightness = brightness
+ self._light.set_brightness(_ha_brightness_to_avea(brightness))
if ATTR_HS_COLOR in kwargs:
rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
self._light.set_rgb(rgb[0], rgb[1], rgb[2])
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
+ if self._attr_brightness:
+ self._last_brightness = self._attr_brightness
self._light.set_brightness(0)
def update(self) -> None:
@@ -206,5 +222,7 @@ def update(self) -> None:
if brightness is not None:
self._attr_is_on = brightness != 0
- self._attr_brightness = round(255 * (brightness / 4095))
+ self._attr_brightness = _avea_brightness_to_ha(brightness)
+ if self._attr_brightness:
+ self._last_brightness = self._attr_brightness
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb_color)
diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py
index 20ad8f514c78be..9e2028bbec26c0 100644
--- a/homeassistant/components/cookidoo/calendar.py
+++ b/homeassistant/components/cookidoo/calendar.py
@@ -10,6 +10,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
@@ -58,7 +59,7 @@ def event(self) -> CalendarEvent | None:
if not self.coordinator.data.week_plan:
return None
- today = date.today() # noqa: DTZ011
+ today = dt_util.now().date()
for day_data in self.coordinator.data.week_plan:
day_date = date.fromisoformat(day_data.id)
if day_date >= today and day_data.recipes:
diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py
index 0fd4875d230838..b920dd87974e39 100644
--- a/homeassistant/components/cookidoo/coordinator.py
+++ b/homeassistant/components/cookidoo/coordinator.py
@@ -1,7 +1,7 @@
"""DataUpdateCoordinator for the Cookidoo integration."""
from dataclasses import dataclass
-from datetime import date, timedelta
+from datetime import timedelta
import logging
from cookidoo_api import (
@@ -21,6 +21,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -81,7 +82,9 @@ async def _async_update_data(self) -> CookidooData:
ingredient_items = await self.cookidoo.get_ingredient_items()
additional_items = await self.cookidoo.get_additional_items()
subscription = await self.cookidoo.get_active_subscription()
- week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) # noqa: DTZ011
+ week_plan = await self.cookidoo.get_recipes_in_calendar_week(
+ dt_util.now().date()
+ )
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
diff --git a/homeassistant/components/data_grand_lyon/__init__.py b/homeassistant/components/data_grand_lyon/__init__.py
index cc5864ea3c4c74..2f33bfe1d4f731 100644
--- a/homeassistant/components/data_grand_lyon/__init__.py
+++ b/homeassistant/components/data_grand_lyon/__init__.py
@@ -1,12 +1,20 @@
"""The Data Grand Lyon integration."""
+import asyncio
+
from data_grand_lyon_ha import DataGrandLyonClient
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
+from .coordinator import (
+ DataGrandLyonConfigEntry,
+ DataGrandLyonData,
+ DataGrandLyonTclCoordinator,
+ DataGrandLyonVelovCoordinator,
+)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -22,10 +30,16 @@ async def async_setup_entry(
password=entry.data[CONF_PASSWORD],
)
- coordinator = DataGrandLyonCoordinator(hass, entry, client)
- await coordinator.async_config_entry_first_refresh()
+ tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client)
+ velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client)
+
+ coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator]
+ await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
- entry.runtime_data = coordinator
+ entry.runtime_data = DataGrandLyonData(
+ tcl_coordinator=tcl_coordinator,
+ velov_coordinator=velov_coordinator,
+ )
entry.async_on_unload(entry.add_update_listener(async_update_entry))
diff --git a/homeassistant/components/data_grand_lyon/binary_sensor.py b/homeassistant/components/data_grand_lyon/binary_sensor.py
index 9b03ec1ac8e7b2..be598660cb45bb 100644
--- a/homeassistant/components/data_grand_lyon/binary_sensor.py
+++ b/homeassistant/components/data_grand_lyon/binary_sensor.py
@@ -31,12 +31,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon binary sensor entities."""
- coordinator = entry.runtime_data
+ velov_coordinator = entry.runtime_data.velov_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
- DataGrandLyonVelovBinarySensor(coordinator, subentry, description)
+ DataGrandLyonVelovBinarySensor(velov_coordinator, subentry, description)
for description in VELOV_BINARY_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -50,6 +50,5 @@ class DataGrandLyonVelovBinarySensor(DataGrandLyonVelovEntity, BinarySensorEntit
def is_on(self) -> bool:
"""Return true if the station is open."""
return (
- self.coordinator.data.velov_stations[self._subentry_id].status
- == VelovStationStatus.OPEN
+ self.coordinator.data[self._subentry_id].status == VelovStationStatus.OPEN
)
diff --git a/homeassistant/components/data_grand_lyon/coordinator.py b/homeassistant/components/data_grand_lyon/coordinator.py
index 234726bf6400a4..70764fdfe1aa89 100644
--- a/homeassistant/components/data_grand_lyon/coordinator.py
+++ b/homeassistant/components/data_grand_lyon/coordinator.py
@@ -28,19 +28,20 @@
SUBENTRY_TYPE_VELOV_STATION,
)
-type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
-
@dataclass
-class DataGrandLyonCoordinatorData:
- """Data returned by the coordinator."""
+class DataGrandLyonData:
+ """Runtime data for the Data Grand Lyon integration."""
+
+ tcl_coordinator: DataGrandLyonTclCoordinator
+ velov_coordinator: DataGrandLyonVelovCoordinator
+
- stops: dict[str, list[TclPassage]]
- velov_stations: dict[str, VelovStation]
+type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData]
-class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorData]):
- """Coordinator for the Data Grand Lyon integration."""
+class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
+ """Coordinator for TCL transit passages."""
config_entry: DataGrandLyonConfigEntry
@@ -56,82 +57,112 @@ def __init__(
hass,
LOGGER,
config_entry=entry,
- name=DOMAIN,
+ name=f"{DOMAIN}_tcl",
update_interval=timedelta(minutes=5),
)
- async def _async_update_data(self) -> DataGrandLyonCoordinatorData:
- """Fetch data for all monitored stops and Vélo'v stations."""
+ async def _async_update_data(self) -> dict[str, list[TclPassage]]:
+ """Fetch data for all monitored stops."""
stop_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
)
+ if not stop_subentries:
+ return {}
+
+ try:
+ all_passages = await self.client.get_tcl_passages()
+ except ClientResponseError as err:
+ if err.status in (401, 403):
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_tcl",
+ ) from err
+ except (ClientError, TimeoutError) as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_tcl",
+ ) from err
+
+ lines_stops = [
+ (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
+ for subentry in stop_subentries
+ ]
+ grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
+ stops: dict[str, list[TclPassage]] = {}
+ for subentry in stop_subentries:
+ key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
+ sorted_passages = sort_tcl_passages_by_time(grouped[key])
+ if sorted_passages:
+ stops[subentry.subentry_id] = sorted_passages
+ else:
+ LOGGER.warning(
+ "No TCL passages found for subentry %s",
+ subentry.subentry_id,
+ )
+ return stops
+
+
+class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation]]):
+ """Coordinator for Vélo'v stations."""
+
+ config_entry: DataGrandLyonConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: DataGrandLyonConfigEntry,
+ client: DataGrandLyonClient,
+ ) -> None:
+ """Initialize the coordinator."""
+ self.client = client
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=entry,
+ name=f"{DOMAIN}_velov",
+ update_interval=timedelta(minutes=5),
+ )
+
+ async def _async_update_data(self) -> dict[str, VelovStation]:
+ """Fetch data for all monitored Vélo'v stations."""
velov_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION)
)
+ if not velov_subentries:
+ return {}
+
+ try:
+ all_stations = await self.client.get_velov_stations()
+ except ClientResponseError as err:
+ if err.status in (401, 403):
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_velov",
+ ) from err
+ except (ClientError, TimeoutError) as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed_velov",
+ ) from err
- has_stops = bool(stop_subentries)
- has_velov = bool(velov_subentries)
- stops: dict[str, list[TclPassage]] = {}
+ station_ids = [subentry.data[CONF_STATION_ID] for subentry in velov_subentries]
+ found = find_velov_stations_by_ids(all_stations, station_ids)
velov_stations: dict[str, VelovStation] = {}
- tcl_success = not has_stops
- velov_success = not has_velov
-
- if has_stops:
- try:
- all_passages = await self.client.get_tcl_passages()
- except ClientResponseError as err:
- if err.status in (401, 403):
- raise ConfigEntryAuthFailed(
- translation_domain=DOMAIN,
- translation_key="auth_failed",
- ) from err
- LOGGER.warning("Error fetching TCL passages: %s", err)
- except (ClientError, TimeoutError) as err:
- LOGGER.warning("Error fetching TCL passages: %s", err)
- else:
- tcl_success = True
- lines_stops = [
- (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
- for subentry in stop_subentries
- ]
- grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
- for subentry in stop_subentries:
- key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
- stops[subentry.subentry_id] = sort_tcl_passages_by_time(
- grouped[key]
- )
-
- if has_velov:
- try:
- all_stations = await self.client.get_velov_stations()
- except ClientResponseError as err:
- if err.status in (401, 403):
- raise ConfigEntryAuthFailed(
- translation_domain=DOMAIN,
- translation_key="auth_failed",
- ) from err
- LOGGER.warning("Error fetching Vélo'v stations: %s", err)
- except (ClientError, TimeoutError) as err:
- LOGGER.warning("Error fetching Vélo'v stations: %s", err)
+ for subentry in velov_subentries:
+ station = found[subentry.data[CONF_STATION_ID]]
+ if station is not None:
+ velov_stations[subentry.subentry_id] = station
else:
- velov_success = True
- station_ids = [
- subentry.data[CONF_STATION_ID] for subentry in velov_subentries
- ]
- found = find_velov_stations_by_ids(all_stations, station_ids)
- for subentry in velov_subentries:
- station = found[subentry.data[CONF_STATION_ID]]
- if station is not None:
- velov_stations[subentry.subentry_id] = station
- else:
- LOGGER.warning(
- "Vélo'v station not found for subentry %s",
- subentry.subentry_id,
- )
-
- if not tcl_success and not velov_success:
- raise UpdateFailed(
- translation_domain=DOMAIN,
- translation_key="update_failed_all",
- )
- return DataGrandLyonCoordinatorData(stops=stops, velov_stations=velov_stations)
+ LOGGER.warning(
+ "Vélo'v station not found for subentry %s",
+ subentry.subentry_id,
+ )
+ return velov_stations
diff --git a/homeassistant/components/data_grand_lyon/diagnostics.py b/homeassistant/components/data_grand_lyon/diagnostics.py
index 4ed43662baf2ff..8e7788e4025d4c 100644
--- a/homeassistant/components/data_grand_lyon/diagnostics.py
+++ b/homeassistant/components/data_grand_lyon/diagnostics.py
@@ -16,18 +16,16 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator = entry.runtime_data
-
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"coordinator_data": {
"stops": {
subentry_id: [asdict(passage) for passage in passages]
- for subentry_id, passages in coordinator.data.stops.items()
+ for subentry_id, passages in entry.runtime_data.tcl_coordinator.data.items()
},
"velov_stations": {
subentry_id: asdict(station)
- for subentry_id, station in coordinator.data.velov_stations.items()
+ for subentry_id, station in entry.runtime_data.velov_coordinator.data.items()
},
},
}
diff --git a/homeassistant/components/data_grand_lyon/entity.py b/homeassistant/components/data_grand_lyon/entity.py
index 4edbc6f7a53066..fb31bc91aec3a3 100644
--- a/homeassistant/components/data_grand_lyon/entity.py
+++ b/homeassistant/components/data_grand_lyon/entity.py
@@ -3,20 +3,25 @@
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
from .const import DOMAIN
-from .coordinator import DataGrandLyonCoordinator
+from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator
-class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
+class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
+ CoordinatorEntity[_CoordinatorT]
+):
"""Base entity for Data Grand Lyon."""
_attr_has_entity_name = True
def __init__(
self,
- coordinator: DataGrandLyonCoordinator,
+ coordinator: _CoordinatorT,
subentry: ConfigSubentry,
description: EntityDescription,
manufacturer: str,
@@ -37,23 +42,33 @@ def __init__(
entry_type=DeviceEntryType.SERVICE,
)
+ @property
+ def available(self) -> bool:
+ """Return True if subentry data is available."""
+ return super().available and self._subentry_id in self.coordinator.data
+
+
+class DataGrandLyonTclEntity(DataGrandLyonEntity[DataGrandLyonTclCoordinator]):
+ """Base entity for Data Grand Lyon TCL stops."""
-class DataGrandLyonVelovEntity(DataGrandLyonEntity):
+ def __init__(
+ self,
+ coordinator: DataGrandLyonTclCoordinator,
+ subentry: ConfigSubentry,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the TCL entity."""
+ super().__init__(coordinator, subentry, description, "TCL", "Stop")
+
+
+class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator]):
"""Base entity for Data Grand Lyon Vélo'v stations."""
def __init__(
self,
- coordinator: DataGrandLyonCoordinator,
+ coordinator: DataGrandLyonVelovCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the Vélo'v entity."""
super().__init__(coordinator, subentry, description, "JCDecaux", "Station")
-
- @property
- def available(self) -> bool:
- """Return True if the station data is available."""
- return (
- super().available
- and self._subentry_id in self.coordinator.data.velov_stations
- )
diff --git a/homeassistant/components/data_grand_lyon/sensor.py b/homeassistant/components/data_grand_lyon/sensor.py
index 16f1d362376566..ff96ce5aaa75f6 100644
--- a/homeassistant/components/data_grand_lyon/sensor.py
+++ b/homeassistant/components/data_grand_lyon/sensor.py
@@ -12,14 +12,13 @@
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION
-from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
-from .entity import DataGrandLyonEntity, DataGrandLyonVelovEntity
+from .coordinator import DataGrandLyonConfigEntry
+from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity
PARALLEL_UPDATES = 0
@@ -170,12 +169,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon sensor entities."""
- coordinator = entry.runtime_data
+ tcl_coordinator = entry.runtime_data.tcl_coordinator
+ velov_coordinator = entry.runtime_data.velov_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
(
- DataGrandLyonStopSensor(coordinator, subentry, description)
+ DataGrandLyonStopSensor(tcl_coordinator, subentry, description)
for description in STOP_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -184,41 +184,31 @@ async def async_setup_entry(
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
- DataGrandLyonVelovSensor(coordinator, subentry, description)
+ DataGrandLyonVelovSensor(velov_coordinator, subentry, description)
for description in VELOV_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
)
-class DataGrandLyonStopSensor(DataGrandLyonEntity, SensorEntity):
+class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity):
"""Sensor for Data Grand Lyon stop departures."""
entity_description: DataGrandLyonStopSensorEntityDescription
- def __init__(
- self,
- coordinator: DataGrandLyonCoordinator,
- subentry: ConfigSubentry,
- description: DataGrandLyonStopSensorEntityDescription,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, subentry, description, "TCL", "Stop")
-
- def _get_departure(self) -> TclPassage | None:
- """Return the departure for this sensor's index, or None."""
- departures = self.coordinator.data.stops.get(self._subentry_id, [])
- index = self.entity_description.departure_index
- if index >= len(departures):
- return None
- return departures[index]
+ @property
+ def available(self) -> bool:
+ """Return True if the departure index exists."""
+ return super().available and self.entity_description.departure_index < len(
+ self.coordinator.data[self._subentry_id]
+ )
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
- departure = self._get_departure()
- if departure is None:
- return None
+ departure = self.coordinator.data[self._subentry_id][
+ self.entity_description.departure_index
+ ]
return self.entity_description.value_fn(departure)
@@ -227,18 +217,9 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity):
entity_description: DataGrandLyonVelovSensorEntityDescription
- def __init__(
- self,
- coordinator: DataGrandLyonCoordinator,
- subentry: ConfigSubentry,
- description: DataGrandLyonVelovSensorEntityDescription,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, subentry, description)
-
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
return self.entity_description.value_fn(
- self.coordinator.data.velov_stations[self._subentry_id]
+ self.coordinator.data[self._subentry_id]
)
diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json
index d03e7078ce0728..697fda1dd0fb43 100644
--- a/homeassistant/components/data_grand_lyon/strings.json
+++ b/homeassistant/components/data_grand_lyon/strings.json
@@ -158,11 +158,11 @@
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
- "update_failed_all": {
- "message": "[%key:component::data_grand_lyon::exceptions::update_failed_all_stops::message%]"
+ "update_failed_tcl": {
+ "message": "Error fetching TCL departures from Data Grand Lyon."
},
- "update_failed_all_stops": {
- "message": "Error fetching Data Grand Lyon data: all requests failed."
+ "update_failed_velov": {
+ "message": "Error fetching Vélo'v stations from Data Grand Lyon."
}
}
}
diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py
index ec5a9f033d2485..69c385643f15a3 100644
--- a/homeassistant/components/dnsip/__init__.py
+++ b/homeassistant/components/dnsip/__init__.py
@@ -1,26 +1,119 @@
"""The DNS IP integration."""
+import asyncio
+from dataclasses import dataclass
+import logging
+
+import aiodns
+from aiodns.error import DNSError
+from pycares import AresError
+
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
-from homeassistant.core import _LOGGER, HomeAssistant
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+from .const import (
+ CONF_HOSTNAME,
+ CONF_IPV4,
+ CONF_IPV6,
+ CONF_PORT_IPV6,
+ CONF_RESOLVER,
+ CONF_RESOLVER_IPV6,
+ DEFAULT_PORT,
+ PLATFORMS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class DnsIPRuntimeData:
+ """Runtime data for DNS IP integration."""
+
+ resolver_ipv4: aiodns.DNSResolver | None
+ resolver_ipv6: aiodns.DNSResolver | None
-from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS
+type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
"""Set up DNS IP from a config entry."""
+ hostname = entry.data[CONF_HOSTNAME]
+ resolver_ipv4: aiodns.DNSResolver | None = None
+ resolver_ipv6: aiodns.DNSResolver | None = None
+ queries: list = []
+
+ if entry.data[CONF_IPV4]:
+ resolver_ipv4 = aiodns.DNSResolver(
+ nameservers=[entry.options[CONF_RESOLVER]],
+ tcp_port=entry.options[CONF_PORT],
+ udp_port=entry.options[CONF_PORT],
+ )
+ queries.append(resolver_ipv4.query(hostname, "A"))
+
+ if entry.data[CONF_IPV6]:
+ resolver_ipv6 = aiodns.DNSResolver(
+ nameservers=[entry.options[CONF_RESOLVER_IPV6]],
+ tcp_port=entry.options[CONF_PORT_IPV6],
+ udp_port=entry.options[CONF_PORT_IPV6],
+ )
+ queries.append(resolver_ipv6.query(hostname, "AAAA"))
+
+ async def _close_resolvers() -> None:
+ if resolver_ipv4 is not None:
+ await resolver_ipv4.close()
+ if resolver_ipv6 is not None:
+ await resolver_ipv6.close()
+
+ try:
+ async with asyncio.timeout(10):
+ results = await asyncio.gather(*queries, return_exceptions=True)
+ except TimeoutError as err:
+ await _close_resolvers()
+ raise ConfigEntryNotReady(
+ f"DNS lookup timed out for {hostname}: {err}"
+ ) from err
+
+ errors = [
+ result
+ for result in results
+ if isinstance(
+ result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
+ )
+ ]
+ if errors and len(errors) == len(results):
+ await _close_resolvers()
+ raise ConfigEntryNotReady(
+ f"DNS lookup failed for {hostname}: {errors[0]}"
+ ) from errors[0]
+
+ entry.runtime_data = DnsIPRuntimeData(
+ resolver_ipv4=resolver_ipv4,
+ resolver_ipv6=resolver_ipv6,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
"""Unload DNS IP config entry."""
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ if unload_ok:
+ if entry.runtime_data.resolver_ipv4 is not None:
+ await entry.runtime_data.resolver_ipv4.close()
+ if entry.runtime_data.resolver_ipv6 is not None:
+ await entry.runtime_data.resolver_ipv6.close()
+ return unload_ok
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: DnsIPConfigEntry
+) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py
index dd5a4f38aab370..ca965f330195e0 100644
--- a/homeassistant/components/dnsip/sensor.py
+++ b/homeassistant/components/dnsip/sensor.py
@@ -4,18 +4,18 @@
from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address
import logging
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
import aiodns
from aiodns.error import DNSError
from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from . import DnsIPConfigEntry
from .const import (
CONF_HOSTNAME,
CONF_IPV4,
@@ -46,7 +46,7 @@ def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list:
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: DnsIPConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the dnsip sensor entry."""
@@ -54,16 +54,29 @@ async def async_setup_entry(
hostname = entry.data[CONF_HOSTNAME]
name = entry.data[CONF_NAME]
- nameserver_ipv4 = entry.options[CONF_RESOLVER]
- nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
- port_ipv4 = entry.options[CONF_PORT]
- port_ipv6 = entry.options[CONF_PORT_IPV6]
-
entities = []
if entry.data[CONF_IPV4]:
- entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
+ entities.append(
+ WanIpSensor(
+ entry,
+ name,
+ hostname,
+ entry.options[CONF_RESOLVER],
+ False,
+ entry.options[CONF_PORT],
+ )
+ )
if entry.data[CONF_IPV6]:
- entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
+ entities.append(
+ WanIpSensor(
+ entry,
+ name,
+ hostname,
+ entry.options[CONF_RESOLVER_IPV6],
+ True,
+ entry.options[CONF_PORT_IPV6],
+ )
+ )
async_add_entities(entities, update_before_add=True)
@@ -75,10 +88,9 @@ class WanIpSensor(SensorEntity):
_attr_translation_key = "dnsip"
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
- resolver: aiodns.DNSResolver
-
def __init__(
self,
+ entry: DnsIPConfigEntry,
name: str,
hostname: str,
nameserver: str,
@@ -86,6 +98,8 @@ def __init__(
port: int,
) -> None:
"""Initialize the DNS IP sensor."""
+ self.entry = entry
+ self.ipv6 = ipv6
self._attr_name = "IPv6" if ipv6 else None
self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname
@@ -104,28 +118,43 @@ def __init__(
model=aiodns.__version__,
name=name,
)
- self.create_dns_resolver()
+
+ @property
+ def _resolver(self) -> aiodns.DNSResolver:
+ """Return the active DNS resolver from runtime data."""
+ resolver = (
+ self.entry.runtime_data.resolver_ipv6
+ if self.ipv6
+ else self.entry.runtime_data.resolver_ipv4
+ )
+ if TYPE_CHECKING:
+ assert resolver is not None
+ return resolver
def create_dns_resolver(self) -> None:
- """Create the DNS resolver."""
- self.resolver = aiodns.DNSResolver(
+ """Create a new DNS resolver and store it on runtime data."""
+ new_resolver = aiodns.DNSResolver(
nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
)
+ if self.ipv6:
+ self.entry.runtime_data.resolver_ipv6 = new_resolver
+ else:
+ self.entry.runtime_data.resolver_ipv4 = new_resolver
async def async_update(self) -> None:
"""Get the current DNS IP address for hostname."""
- if self.resolver._closed: # noqa: SLF001
+ if self._resolver._closed: # noqa: SLF001
self.create_dns_resolver()
response = None
try:
async with asyncio.timeout(10):
- response = await self.resolver.query(self.hostname, self.querytype)
+ response = await self._resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
- await self.resolver.close()
+ await self._resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
- await self.resolver.close()
+ await self._resolver.close()
if response:
sorted_ips = sort_ips(
diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py
index f4ca817f7e9d40..380182912c0e11 100644
--- a/homeassistant/components/elkm1/entity.py
+++ b/homeassistant/components/elkm1/entity.py
@@ -47,6 +47,23 @@ def create_elk_entities(
return entities
+def generate_unique_id(prefix: str, element: Element) -> str:
+ """Generate a unique id."""
+ # unique_id starts with elkm1_ iff there is no prefix
+ # it starts with elkm1m_{prefix} iff there is a prefix
+ # this is to avoid a conflict between
+ # prefix=foo, name=bar (which would be elkm1_foo_bar)
+ # - and -
+ # prefix="", name="foo bar" (which would be elkm1_foo_bar also)
+ # we could have used elkm1__foo_bar for the latter, but that
+ # would have been a breaking change
+ if prefix != "":
+ uid_start = f"elkm1m_{prefix}"
+ else:
+ uid_start = "elkm1"
+ return f"{uid_start}_{element.default_name('_')}".lower()
+
+
class ElkEntity(Entity):
"""Base class for all Elk entities."""
@@ -60,19 +77,7 @@ def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None:
self._mac = elk_data.mac
self._prefix = elk_data.prefix
self._temperature_unit: str = elk_data.config["temperature_unit"]
- # unique_id starts with elkm1_ iff there is no prefix
- # it starts with elkm1m_{prefix} iff there is a prefix
- # this is to avoid a conflict between
- # prefix=foo, name=bar (which would be elkm1_foo_bar)
- # - and -
- # prefix="", name="foo bar" (which would be elkm1_foo_bar also)
- # we could have used elkm1__foo_bar for the latter, but that
- # would have been a breaking change
- if self._prefix != "":
- uid_start = f"elkm1m_{self._prefix}"
- else:
- uid_start = "elkm1"
- self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower()
+ self._unique_id = generate_unique_id(self._prefix, element)
self._attr_name = element.name
@property
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index 6949a915f3c7c2..399713cfb4c5fe 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -1,6 +1,6 @@
"""Support for control of ElkM1 sensors."""
-from typing import Any
+from typing import Any, cast
from elkm1_lib.const import SettingFormat, ZoneType
from elkm1_lib.counters import Counter
@@ -20,13 +20,19 @@
from homeassistant.const import EntityCategory, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_platform
+from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ElkM1ConfigEntry
from .const import ATTR_VALUE, ELK_USER_CODE_SERVICE_SCHEMA
-from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
+from .entity import (
+ ElkAttachedEntity,
+ ElkEntity,
+ create_elk_entities,
+ generate_unique_id,
+)
+from .util import deprecate_entity
SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh"
SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set"
@@ -58,11 +64,37 @@ async def async_setup_entry(
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
+ elk_settings: list[Setting] = []
+
create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities)
create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities)
create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities)
- create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities)
create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
+
+ entity_registry = er.async_get(hass)
+ for setting in elk.settings:
+ setting = cast(Setting, setting)
+ domain = (
+ "time" if setting.value_format == SettingFormat.TIME_OF_DAY else "number"
+ )
+
+ orig_unique_id = generate_unique_id(elk_data.prefix, setting)
+ new_unique_id = orig_unique_id
+ new_entity_id = f"{domain}.elkm1_{setting.name.replace(' ', '_')}".lower()
+
+ if deprecate_entity(
+ hass,
+ entity_registry,
+ "sensor",
+ orig_unique_id,
+ f"deprecated_sensor_{orig_unique_id}",
+ "deprecated_sensor",
+ new_unique_id,
+ new_entity_id,
+ ):
+ elk_settings.append(setting)
+
+ create_elk_entities(elk_data, elk_settings, "setting", ElkSetting, entities)
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json
index fb3e09ee2fb53f..23514027addab7 100644
--- a/homeassistant/components/elkm1/strings.json
+++ b/homeassistant/components/elkm1/strings.json
@@ -58,6 +58,16 @@
}
}
},
+ "issues": {
+ "deprecated_sensor": {
+ "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
+ "title": "Deprecated sensor detected"
+ },
+ "deprecated_sensor_scripts": {
+ "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
+ "title": "[%key:component::elkm1::issues::deprecated_sensor::title%]"
+ }
+ },
"services": {
"alarm_arm_home_instant": {
"description": "Arms the Elk-M1 in home instant mode.",
diff --git a/homeassistant/components/elkm1/util.py b/homeassistant/components/elkm1/util.py
new file mode 100644
index 00000000000000..50b91e7169894c
--- /dev/null
+++ b/homeassistant/components/elkm1/util.py
@@ -0,0 +1,102 @@
+"""Utility helpers for the elkm1 integration."""
+
+from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.script import scripts_with_entity
+from homeassistant.const import Platform
+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
+
+
+def deprecate_entity(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ platform_domain: str,
+ entity_unique_id: str,
+ issue_id: str,
+ issue_string: str,
+ replacement_entity_unique_id: str,
+ replacement_entity_id: str,
+ version: str = "2026.11.0",
+) -> bool:
+ """Create an issue for deprecated entities."""
+ if entity_id := entity_registry.async_get_entity_id(
+ platform_domain, DOMAIN, entity_unique_id
+ ):
+ entity_entry = entity_registry.async_get(entity_id)
+ if not entity_entry:
+ async_delete_issue(hass, DOMAIN, issue_id)
+ return False
+
+ items = get_automations_and_scripts_using_entity(hass, entity_id)
+ if entity_entry.disabled and not items:
+ entity_registry.async_remove(entity_id)
+ async_delete_issue(hass, DOMAIN, issue_id)
+ return False
+
+ translation_key = issue_string
+ placeholders = {
+ "entity_id": entity_id,
+ "entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
+ "replacement_entity_id": (
+ entity_registry.async_get_entity_id(
+ Platform.NUMBER, DOMAIN, replacement_entity_unique_id
+ )
+ or entity_registry.async_get_entity_id(
+ Platform.TIME, DOMAIN, replacement_entity_unique_id
+ )
+ or replacement_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=version,
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key=translation_key,
+ translation_placeholders=placeholders,
+ )
+ return True
+
+ async_delete_issue(hass, DOMAIN, issue_id)
+ return False
+
+
+def get_automations_and_scripts_using_entity(
+ hass: HomeAssistant,
+ entity_id: str,
+) -> list[str]:
+ """Get 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 []
+
+ entity_registry = er.async_get(hass)
+ items: list[str] = []
+
+ for integration, entities in (
+ ("automation", automations),
+ ("script", scripts),
+ ):
+ for used_entity_id in entities:
+ if item := entity_registry.async_get(used_entity_id):
+ items.append(
+ f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
+ )
+ else:
+ items.append(f"- `{used_entity_id}`")
+
+ return items
diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py
index 465b2f9d587095..d5f46a66650a13 100644
--- a/homeassistant/components/enphase_envoy/const.py
+++ b/homeassistant/components/enphase_envoy/const.py
@@ -16,6 +16,9 @@
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
+SETUP_RETRY_TIMEOUT = 50
+OPERATIONAL_RETRY_TIMEOUT = 200
+
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES = "diagnostics_include_fixtures"
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE = False
diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py
index 2bb93e932fe20f..288b89883faa17 100644
--- a/homeassistant/components/enphase_envoy/coordinator.py
+++ b/homeassistant/components/enphase_envoy/coordinator.py
@@ -18,7 +18,12 @@
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
-from .const import DOMAIN, INVALID_AUTH_ERRORS
+from .const import (
+ DOMAIN,
+ INVALID_AUTH_ERRORS,
+ OPERATIONAL_RETRY_TIMEOUT,
+ SETUP_RETRY_TIMEOUT,
+)
SCAN_INTERVAL = timedelta(seconds=60)
@@ -50,6 +55,7 @@ def __init__(
self.username = entry_data[CONF_USERNAME]
self.password = entry_data[CONF_PASSWORD]
self._setup_complete = False
+ self._operational_timeout = False
self.envoy_firmware = ""
self.interface = None
self._cancel_token_refresh: CALLBACK_TYPE | None = None
@@ -265,10 +271,15 @@ async def _async_update_data(self) -> dict[str, Any]:
try:
if not self._setup_complete:
_LOGGER.debug("update on try %s, setup not complete", tries)
+ self.envoy.set_retry_policy(max_delay=SETUP_RETRY_TIMEOUT)
+ self._operational_timeout = False
await self._async_setup_and_authenticate()
self._async_mark_setup_complete()
# dump all received data in debug mode to assist troubleshooting
envoy_data = await envoy.update()
+ if not self._operational_timeout:
+ self.envoy.set_retry_policy(max_delay=OPERATIONAL_RETRY_TIMEOUT)
+ self._operational_timeout = True
except INVALID_AUTH_ERRORS as err:
_LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err)
if self._setup_complete and tries == 0:
diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py
index 69be911f8f14c8..9365b2fea32831 100644
--- a/homeassistant/components/fritz/__init__.py
+++ b/homeassistant/components/fritz/__init__.py
@@ -54,6 +54,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
),
)
+ hass.data.setdefault(FRITZ_DATA_KEY, FritzData())
+
try:
await avm_wrapper.async_setup(entry.options)
except FRITZ_AUTH_EXCEPTIONS as ex:
@@ -68,13 +70,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
raise ConfigEntryAuthFailed("Missing UPnP configuration")
await avm_wrapper.async_config_entry_first_refresh()
- await avm_wrapper.async_trigger_cleanup()
entry.runtime_data = avm_wrapper
- if FRITZ_DATA_KEY not in hass.data:
- hass.data[FRITZ_DATA_KEY] = FritzData()
-
# Load the other platforms like switch
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -89,6 +87,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo
if avm_wrapper.unique_id in fritz_data.tracked:
fritz_data.tracked.pop(avm_wrapper.unique_id)
+ fritz_data.profile_switches.pop(avm_wrapper.unique_id)
+ fritz_data.wol_buttons.pop(avm_wrapper.unique_id)
if not bool(fritz_data.tracked):
hass.data.pop(FRITZ_DATA_KEY)
diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py
index bc7dd6c3d49d57..7fdb0ae9e5c6df 100644
--- a/homeassistant/components/fritz/button.py
+++ b/homeassistant/components/fritz/button.py
@@ -217,9 +217,6 @@ def _async_wol_buttons_list(
new_wols: list[FritzBoxWOLButton] = []
- if avm_wrapper.unique_id not in data_fritz.wol_buttons:
- data_fritz.wol_buttons[avm_wrapper.unique_id] = set()
-
for mac, device in avm_wrapper.devices.items():
if _is_tracked(mac, data_fritz.wol_buttons.values()):
_LOGGER.debug("Skipping wol button creation for device %s", device.hostname)
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 571214d1d85c3c..5915804aa1386d 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -187,6 +187,10 @@ async def async_setup(self, options: Mapping[str, Any] | None = None) -> None:
self._options = options
await self.hass.async_add_executor_job(self.setup)
+ self.hass.data[FRITZ_DATA_KEY].tracked[self.unique_id] = set()
+ self.hass.data[FRITZ_DATA_KEY].profile_switches[self.unique_id] = set()
+ self.hass.data[FRITZ_DATA_KEY].wol_buttons[self.unique_id] = set()
+
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
@@ -715,6 +719,7 @@ async def async_trigger_cleanup(self) -> None:
) and entry_mac not in device_hosts:
_LOGGER.debug("Removing orphan entity entry %s", entity.entity_id)
entity_reg.async_remove(entity.entity_id)
+ self._devices.pop(entry_mac, None)
device_reg = dr.async_get(self.hass)
valid_connections = {
@@ -729,6 +734,29 @@ async def async_trigger_cleanup(self) -> None:
device.id, remove_config_entry_id=config_entry.entry_id
)
+ fritz_data = self.hass.data[FRITZ_DATA_KEY]
+
+ tracked = fritz_data.tracked.get(self.unique_id, set())
+ for mac in tracked.copy():
+ if mac in device_hosts:
+ continue
+ _LOGGER.debug("Removing orphan mac address %s from device trackers", mac)
+ tracked.remove(mac)
+
+ profile_switches = fritz_data.profile_switches.get(self.unique_id, set())
+ for mac in profile_switches.copy():
+ if mac in device_hosts:
+ continue
+ _LOGGER.debug("Removing orphan mac address %s from profile switches", mac)
+ profile_switches.remove(mac)
+
+ wol_buttons = fritz_data.wol_buttons.get(self.unique_id, set())
+ for mac in wol_buttons.copy():
+ if mac in device_hosts:
+ continue
+ _LOGGER.debug("Removing orphan mac address %s from WOL buttons", mac)
+ wol_buttons.remove(mac)
+
class AvmWrapper(FritzBoxTools):
"""Setup AVM wrapper for API calls."""
diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py
index 22248c8e32bc94..26b04a6ea68546 100644
--- a/homeassistant/components/fritz/device_tracker.py
+++ b/homeassistant/components/fritz/device_tracker.py
@@ -51,9 +51,6 @@ def _async_add_entities(
"""Add new tracker entities from the AVM device."""
new_tracked = []
- if avm_wrapper.unique_id not in data_fritz.tracked:
- data_fritz.tracked[avm_wrapper.unique_id] = set()
-
for mac, device in avm_wrapper.devices.items():
if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()):
continue
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index c62453a581ec48..28a6d21dc2ced1 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -242,9 +242,6 @@ async def _async_profile_entities_list(
if "X_AVM-DE_HostFilter1" not in avm_wrapper.connection.services:
return new_profiles
- if avm_wrapper.unique_id not in data_fritz.profile_switches:
- data_fritz.profile_switches[avm_wrapper.unique_id] = set()
-
for mac, device in avm_wrapper.devices.items():
if device_filter_out_from_trackers(
mac, device, data_fritz.profile_switches.values()
diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json
index ade1a5627eb8f8..7cc16c1979a243 100644
--- a/homeassistant/components/immich/manifest.json
+++ b/homeassistant/components/immich/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
- "requirements": ["aioimmich==0.14.0"]
+ "requirements": ["aioimmich==0.14.1"]
}
diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py
index 90c6b4b720ad4c..498b54ef7cd685 100644
--- a/homeassistant/components/matter/discovery.py
+++ b/homeassistant/components/matter/discovery.py
@@ -21,6 +21,7 @@
from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS
from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS
from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS
+from .siren import DISCOVERY_SCHEMAS as SIREN_SCHEMAS
from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS
from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS
from .vacuum import DISCOVERY_SCHEMAS as VACUUM_SCHEMAS
@@ -39,6 +40,7 @@
Platform.NUMBER: NUMBER_SCHEMAS,
Platform.SELECT: SELECT_SCHEMAS,
Platform.SENSOR: SENSOR_SCHEMAS,
+ Platform.SIREN: SIREN_SCHEMAS,
Platform.SWITCH: SWITCH_SCHEMAS,
Platform.UPDATE: UPDATE_SCHEMAS,
Platform.VACUUM: VACUUM_SCHEMAS,
diff --git a/homeassistant/components/matter/siren.py b/homeassistant/components/matter/siren.py
new file mode 100644
index 00000000000000..c07a4346492540
--- /dev/null
+++ b/homeassistant/components/matter/siren.py
@@ -0,0 +1,69 @@
+"""Matter sirens."""
+
+from dataclasses import dataclass
+from typing import Any
+
+from matter_server.common.custom_clusters import HeimanCluster
+
+from homeassistant.components.siren import (
+ SirenEntity,
+ SirenEntityDescription,
+ SirenEntityFeature,
+)
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .entity import MatterEntity, MatterEntityDescription
+from .helpers import MatterConfigEntry
+from .models import MatterDiscoverySchema
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: MatterConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Matter sirens from Config Entry."""
+ matter = config_entry.runtime_data.adapter
+ matter.register_platform_handler(Platform.SIREN, async_add_entities)
+
+
+@dataclass(frozen=True, kw_only=True)
+class MatterSirenEntityDescription(SirenEntityDescription, MatterEntityDescription):
+ """Describe Matter Siren entities."""
+
+
+class MatterSiren(MatterEntity, SirenEntity):
+ """Representation of a Matter siren."""
+
+ entity_description: MatterSirenEntityDescription
+ _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the siren on."""
+ await self.write_attribute(value=1)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the siren off."""
+ await self.write_attribute(value=0)
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
+ self._attr_is_on = bool(value) if value is not None else None
+
+
+# Discovery schema(s) to map Matter Attributes to HA entities
+DISCOVERY_SCHEMAS = [
+ MatterDiscoverySchema(
+ platform=Platform.SIREN,
+ entity_description=MatterSirenEntityDescription(
+ key="HeimanSiren",
+ translation_key="siren",
+ ),
+ entity_class=MatterSiren,
+ required_attributes=(HeimanCluster.Attributes.SirenActive,),
+ ),
+]
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index 514ba606fa518e..47a013d559e4ab 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -610,6 +610,11 @@
"name": "Target opening position"
}
},
+ "siren": {
+ "siren": {
+ "name": "[%key:component::siren::title%]"
+ }
+ },
"switch": {
"child_lock": {
"name": "Child lock"
diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py
index 9bac640337c317..6a255959204072 100644
--- a/homeassistant/components/prusalink/binary_sensor.py
+++ b/homeassistant/components/prusalink/binary_sensor.py
@@ -7,6 +7,7 @@
from pyprusalink.types_legacy import LegacyPrinterStatus
from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@@ -30,6 +31,17 @@ class PrusaLinkBinarySensorEntityDescription[
BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = {
+ "status": (
+ PrusaLinkBinarySensorEntityDescription[PrinterStatus](
+ key="printer.status_connect",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ value_fn=lambda data: data["printer"]["status_connect"]["ok"],
+ supported_fn=lambda data: (
+ data["printer"].get("status_connect") is not None
+ and data["printer"]["status_connect"].get("ok") is not None
+ ),
+ ),
+ ),
"info": (
PrusaLinkBinarySensorEntityDescription[PrinterInfo](
key="info.mmu",
@@ -37,6 +49,20 @@ class PrusaLinkBinarySensorEntityDescription[
value_fn=lambda data: data["mmu"],
entity_registry_enabled_default=False,
),
+ PrusaLinkBinarySensorEntityDescription[PrinterInfo](
+ key="info.sd_ready",
+ translation_key="sd_ready",
+ value_fn=lambda data: data["sd_ready"],
+ supported_fn=lambda data: data.get("sd_ready") is not None,
+ entity_registry_enabled_default=False,
+ ),
+ PrusaLinkBinarySensorEntityDescription[PrinterInfo](
+ key="info.farm_mode",
+ translation_key="farm_mode",
+ value_fn=lambda data: data["farm_mode"],
+ supported_fn=lambda data: data.get("farm_mode") is not None,
+ entity_registry_enabled_default=False,
+ ),
),
}
@@ -55,6 +81,7 @@ async def async_setup_entry(
entities.extend(
PrusaLinkBinarySensorEntity(coordinator, sensor_description)
for sensor_description in binary_sensors
+ if sensor_description.supported_fn(coordinator.data)
)
async_add_entities(entities)
diff --git a/homeassistant/components/prusalink/icons.json b/homeassistant/components/prusalink/icons.json
index f2ce5b412f18bf..59f6dcc823e5c7 100644
--- a/homeassistant/components/prusalink/icons.json
+++ b/homeassistant/components/prusalink/icons.json
@@ -1,5 +1,16 @@
{
"entity": {
+ "binary_sensor": {
+ "farm_mode": {
+ "default": "mdi:server-network"
+ },
+ "mmu": {
+ "default": "mdi:printer-3d-nozzle-alert"
+ },
+ "sd_ready": {
+ "default": "mdi:micro-sd"
+ }
+ },
"button": {
"cancel_job": {
"default": "mdi:cancel"
diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json
index 7fb06d19569087..153c3d9a8237d5 100644
--- a/homeassistant/components/prusalink/strings.json
+++ b/homeassistant/components/prusalink/strings.json
@@ -18,8 +18,14 @@
},
"entity": {
"binary_sensor": {
+ "farm_mode": {
+ "name": "Farm mode"
+ },
"mmu": {
"name": "MMU"
+ },
+ "sd_ready": {
+ "name": "SD card"
}
},
"button": {
diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py
index 3ecab64059aea2..3cf2a302f12199 100644
--- a/homeassistant/components/qbus/const.py
+++ b/homeassistant/components/qbus/const.py
@@ -11,6 +11,7 @@
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
+ Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
diff --git a/homeassistant/components/qbus/select.py b/homeassistant/components/qbus/select.py
new file mode 100644
index 00000000000000..0827eddd58d5ca
--- /dev/null
+++ b/homeassistant/components/qbus/select.py
@@ -0,0 +1,91 @@
+"""Support for Qbus select."""
+
+from qbusmqttapi.const import KEY_PROPERTIES_VALUE
+from qbusmqttapi.discovery import QbusMqttOutput
+from qbusmqttapi.state import QbusMqttStepperState, StateType
+
+from homeassistant.components.select import SelectEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import QbusConfigEntry
+from .entity import QbusEntity, create_new_entities
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: QbusConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up select entities."""
+
+ coordinator = entry.runtime_data
+ added_outputs: list[QbusMqttOutput] = []
+
+ def _check_outputs() -> None:
+ """Add newly discovered outputs as entities."""
+ entities = create_new_entities(
+ coordinator,
+ added_outputs,
+ lambda output: output.type == "stepper",
+ QbusStepper,
+ )
+
+ async_add_entities(entities)
+
+ _check_outputs()
+ entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+
+
+class QbusStepper(QbusEntity, SelectEntity):
+ """Representation of a Qbus stepper entity."""
+
+ _state_cls = QbusMqttStepperState
+
+ def __init__(self, mqtt_output: QbusMqttOutput) -> None:
+ """Initialize stepper entity."""
+
+ super().__init__(mqtt_output, link_to_main_device=True)
+
+ self._attr_name = mqtt_output.name.title()
+
+ value_settings: dict = mqtt_output.properties.get(KEY_PROPERTIES_VALUE, {})
+ value_list: list[dict] = value_settings.get("valueList", [])
+
+ self._name_to_value: dict[str, int] = {
+ item["name"]: item["value"] for item in value_list
+ }
+ self._value_to_name: dict[int, str] = {
+ item["value"]: item["name"] for item in value_list
+ }
+ self._attr_options = [item["name"] for item in value_list]
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ value = self._name_to_value.get(option)
+
+ if value is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_option",
+ translation_placeholders={
+ "option": option,
+ "options": ", ".join(self._attr_options),
+ },
+ )
+
+ state = QbusMqttStepperState(id=self._mqtt_output.id, type=StateType.STATE)
+ state.write_value(value)
+
+ await self._async_publish_output_state(state)
+
+ async def _handle_state_received(self, state: QbusMqttStepperState) -> None:
+ """Update the state from a received Qbus state."""
+ value = state.read_value()
+
+ if value is not None:
+ self._attr_current_option = self._value_to_name.get(value)
diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json
index 2f7e9afc3e447b..58335d2c80a5fe 100644
--- a/homeassistant/components/qbus/strings.json
+++ b/homeassistant/components/qbus/strings.json
@@ -41,6 +41,9 @@
}
},
"exceptions": {
+ "invalid_option": {
+ "message": "Option \"{option}\" is not valid. Valid options are: {options}."
+ },
"invalid_preset": {
"message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}."
}
diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py
index bcea427865f0bd..6fe1801eef847c 100644
--- a/homeassistant/components/recollect_waste/calendar.py
+++ b/homeassistant/components/recollect_waste/calendar.py
@@ -7,6 +7,7 @@
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util import dt as dt_util
from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator
from .entity import ReCollectWasteEntity
@@ -68,7 +69,7 @@ def _handle_coordinator_update(self) -> None:
current_event = next(
event
for event in self.coordinator.data
- if event.date >= datetime.date.today() # noqa: DTZ011
+ if event.date >= dt_util.now().date()
)
except StopIteration:
self._event = None
diff --git a/homeassistant/components/recollect_waste/coordinator.py b/homeassistant/components/recollect_waste/coordinator.py
index 69eba67e20dd82..ab652e4c1257de 100644
--- a/homeassistant/components/recollect_waste/coordinator.py
+++ b/homeassistant/components/recollect_waste/coordinator.py
@@ -1,6 +1,6 @@
"""Data update coordinator for ReCollect Waste."""
-from datetime import date, timedelta
+from datetime import timedelta
from aiorecollect.client import Client, PickupEvent
from aiorecollect.errors import RecollectError
@@ -9,6 +9,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import dt as dt_util
from .const import CONF_PLACE_ID, CONF_SERVICE_ID, LOGGER
@@ -52,7 +53,7 @@ async def _async_update_data(self) -> list[PickupEvent]:
# This ensures that data about when the next pickup is will be
# returned when the next pickup is the first day of the next month.
# Ex: Today is August 31st, tomorrow is a pickup on September 1st.
- today = date.today() # noqa: DTZ011
+ today = dt_util.now().date()
return await self._client.async_get_pickup_events(
start_date=today,
end_date=today + timedelta(days=35),
diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py
index d7f710f74ff2f1..f3d565241379e2 100644
--- a/homeassistant/components/recollect_waste/sensor.py
+++ b/homeassistant/components/recollect_waste/sensor.py
@@ -1,7 +1,5 @@
"""Support for ReCollect Waste sensors."""
-from datetime import date
-
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -9,6 +7,7 @@
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util import dt as dt_util
from .const import LOGGER
from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator
@@ -70,7 +69,9 @@ def __init__(
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
- relevant_events = (e for e in self.coordinator.data if e.date >= date.today()) # noqa: DTZ011
+ relevant_events = (
+ e for e in self.coordinator.data if e.date >= dt_util.now().date()
+ )
pickup_index = self.PICKUP_INDEX_MAP[self.entity_description.key]
try:
diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py
index ce107c33d7c8a1..c2cd2fce84a9a1 100644
--- a/homeassistant/components/tractive/binary_sensor.py
+++ b/homeassistant/components/tractive/binary_sensor.py
@@ -57,7 +57,6 @@ class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription):
SENSOR_TYPES = [
TractiveBinarySensorEntityDescription(
key=ATTR_BATTERY_CHARGING,
- translation_key="tracker_battery_charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
supported=lambda details: details.get("charging_state") is not None,
diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py
index 27e51285f7220f..48e80959d48e3a 100644
--- a/homeassistant/components/tractive/entity.py
+++ b/homeassistant/components/tractive/entity.py
@@ -3,7 +3,7 @@
from typing import Any
from homeassistant.core import callback
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -22,16 +22,26 @@ def __init__(
trackable: dict[str, Any],
tracker_details: dict[str, Any],
dispatcher_signal: str,
+ hardware_entity: bool = True,
) -> None:
"""Initialize tracker entity."""
- self._attr_device_info = DeviceInfo(
- configuration_url="https://my.tractive.com/",
- identifiers={(DOMAIN, tracker_details["_id"])},
- name=trackable["details"]["name"],
- manufacturer="Tractive GmbH",
- sw_version=tracker_details["fw_version"],
- model=tracker_details["model_number"],
- )
+ if hardware_entity:
+ self._attr_device_info = DeviceInfo(
+ configuration_url="https://my.tractive.com/",
+ identifiers={(DOMAIN, tracker_details["_id"])},
+ name=f"Tracker {tracker_details['_id']}",
+ manufacturer="Tractive GmbH",
+ sw_version=tracker_details["fw_version"],
+ model_id=tracker_details["model_number"],
+ )
+ else:
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, trackable["_id"])},
+ name=trackable["details"]["name"],
+ via_device=(DOMAIN, tracker_details["_id"]),
+ entry_type=DeviceEntryType.SERVICE,
+ )
+
self._user_id = client.user_id
self._tracker_id = tracker_details["_id"]
self._client = client
diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py
index 0740ae2c36885d..82044de40beaca 100644
--- a/homeassistant/components/tractive/sensor.py
+++ b/homeassistant/components/tractive/sensor.py
@@ -63,7 +63,11 @@ def __init__(
else:
dispatcher_signal = f"{description.signal_prefix}-{item.trackable['_id']}"
super().__init__(
- client, item.trackable, item.tracker_details, dispatcher_signal
+ client,
+ item.trackable,
+ item.tracker_details,
+ dispatcher_signal,
+ description.hardware_sensor,
)
self._attr_unique_id = f"{item.trackable['_id']}_{description.key}"
@@ -83,7 +87,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None:
SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
TractiveSensorEntityDescription(
key=ATTR_BATTERY_LEVEL,
- translation_key="tracker_battery_level",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED,
diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json
index 55f7f958260ad0..bf5e75a2bec738 100644
--- a/homeassistant/components/tractive/strings.json
+++ b/homeassistant/components/tractive/strings.json
@@ -22,16 +22,8 @@
},
"entity": {
"binary_sensor": {
- "tracker_battery_charging": {
- "name": "Tracker battery charging"
- },
"tracker_power_saving": {
- "name": "Tracker power saving"
- }
- },
- "device_tracker": {
- "tracker": {
- "name": "Tracker"
+ "name": "Power saving"
}
},
"sensor": {
@@ -50,11 +42,8 @@
"rest_time": {
"name": "Rest time"
},
- "tracker_battery_level": {
- "name": "Tracker battery"
- },
"tracker_state": {
- "name": "Tracker state",
+ "name": "Status",
"state": {
"inaccurate_position": "Inaccurate position",
"not_reporting": "Not reporting",
@@ -69,10 +58,10 @@
"name": "Live tracking"
},
"tracker_buzzer": {
- "name": "Tracker buzzer"
+ "name": "Buzzer"
},
"tracker_led": {
- "name": "Tracker LED"
+ "name": "LED"
}
}
},
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index 1c4812f60a0d2e..447828ab7bd8c7 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -19,7 +19,6 @@
from .const import (
DEVICE_CLASS_UNITS,
- DOMAIN,
LOGGER,
TUYA_DISCOVERY_NEW,
DeviceCategory,
@@ -516,55 +515,53 @@ def __init__(
self._attr_native_max_value = definition.number_wrapper.max_value
self._attr_native_min_value = definition.number_wrapper.min_value
self._attr_native_step = definition.number_wrapper.value_step
- if description.native_unit_of_measurement is None:
- self._attr_native_unit_of_measurement = (
- definition.number_wrapper.native_unit
- )
- self._validate_device_class_unit()
+ self._validate_device_class_unit(definition.number_wrapper.native_unit)
- def _validate_device_class_unit(self) -> None:
+ def _validate_device_class_unit(self, tuya_uom: str | None) -> None:
"""Validate device class unit compatibility."""
# Logic to ensure the set device class and API received Unit Of Measurement
# match Home Assistants requirements.
if (
- self.device_class is not None
- and not self.device_class.startswith(DOMAIN)
- and self.entity_description.native_unit_of_measurement is None
+ (device_class := self.device_class) is None
# we do not need to check mappings if the API UOM is allowed
- and self.native_unit_of_measurement
- not in NUMBER_DEVICE_CLASS_UNITS[self.device_class]
+ or tuya_uom in NUMBER_DEVICE_CLASS_UNITS[device_class]
):
- # We cannot have a device class, if the UOM isn't set or the
- # device class cannot be found in the validation mapping.
- if (
- self.native_unit_of_measurement is None
- or self.device_class not in DEVICE_CLASS_UNITS
- ):
- LOGGER.debug(
- "Device class %s ignored for"
- " incompatible unit %s in number entity %s",
- self.device_class,
- self.native_unit_of_measurement,
- self.unique_id,
- )
- self._attr_device_class = None
- return
+ self._attr_native_unit_of_measurement = tuya_uom
+ return
+
+ # Check mappings for compatible units of measurement for the device class
+ if (
+ tuya_uom is not None
+ and (uoms := DEVICE_CLASS_UNITS.get(device_class))
+ and (uom := uoms.get(tuya_uom) or uoms.get(tuya_uom.lower()))
+ ):
+ self._attr_native_unit_of_measurement = uom.unit
+ return
- uoms = DEVICE_CLASS_UNITS[self.device_class]
- uom = uoms.get(self.native_unit_of_measurement) or uoms.get(
- self.native_unit_of_measurement.lower()
+ if self.entity_description.native_unit_of_measurement is not None:
+ LOGGER.debug(
+ "Incompatible unit %s replaced by entity description unit %s "
+ "for device class %s in number entity %s; use a quirk "
+ "(https://github.com/home-assistant-libs/tuya-device-handlers)"
+ " to override",
+ tuya_uom,
+ self.entity_description.native_unit_of_measurement,
+ device_class,
+ self.unique_id,
)
- # Unknown unit of measurement, device class should not be used.
- if uom is None:
- self._attr_device_class = None
- return
+ return
- # Found unit of measurement, use the standardized Unit
- # Use the target conversion unit (if set)
- self._attr_native_unit_of_measurement = uom.unit
+ self._attr_native_unit_of_measurement = tuya_uom
+ self._attr_device_class = None
+ LOGGER.debug(
+ "Device class %s ignored for incompatible unit %s in number entity %s",
+ device_class,
+ tuya_uom,
+ self.unique_id,
+ )
@property
def native_value(self) -> float | None:
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index b3240f3b80e79e..54374234b3179d 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -47,7 +47,6 @@
from .const import (
DEVICE_CLASS_UNITS,
- DOMAIN,
LOGGER,
TUYA_DISCOVERY_NEW,
DeviceCategory,
@@ -1729,10 +1728,6 @@ def __init__(
super().__init__(device, device_manager, description)
self._dpcode_wrapper = definition.sensor_wrapper
- if description.native_unit_of_measurement is None:
- self._attr_native_unit_of_measurement = (
- definition.sensor_wrapper.native_unit
- )
if description.suggested_unit_of_measurement is None:
self._attr_suggested_unit_of_measurement = (
definition.sensor_wrapper.suggested_unit
@@ -1752,53 +1747,57 @@ def __init__(
):
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
- self._validate_device_class_unit()
+ self._validate_device_class_unit(definition.sensor_wrapper.native_unit)
- def _validate_device_class_unit(self) -> None:
+ def _validate_device_class_unit(self, tuya_uom: str | None) -> None:
"""Validate device class unit compatibility."""
# Logic to ensure the set device class and API received Unit Of Measurement
# match Home Assistants requirements.
if (
- self.device_class is not None
- and self.device_class != SensorDeviceClass.ENUM
- and not self.device_class.startswith(DOMAIN)
- and self.entity_description.native_unit_of_measurement is None
+ device_class := self.entity_description.device_class
+ ) is SensorDeviceClass.ENUM:
+ self._attr_native_unit_of_measurement = None
+ return
+ if (
+ device_class is None
# we do not need to check mappings if the API UOM is allowed
- and self.native_unit_of_measurement
- not in SENSOR_DEVICE_CLASS_UNITS[self.device_class]
+ or tuya_uom in SENSOR_DEVICE_CLASS_UNITS[device_class]
):
- # We cannot have a device class, if the UOM isn't set or the
- # device class cannot be found in the validation mapping.
- if (
- self.native_unit_of_measurement is None
- or self.device_class not in DEVICE_CLASS_UNITS
- ):
- LOGGER.debug(
- "Device class %s ignored for"
- " incompatible unit %s in sensor entity %s",
- self.device_class,
- self.native_unit_of_measurement,
- self.unique_id,
- )
- self._attr_device_class = None
- self._attr_suggested_unit_of_measurement = None
- return
+ self._attr_native_unit_of_measurement = tuya_uom
+ return
- uoms = DEVICE_CLASS_UNITS[self.device_class]
- uom = uoms.get(self.native_unit_of_measurement) or uoms.get(
- self.native_unit_of_measurement.lower()
- )
+ # Check mappings for compatible units of measurement for the device class
+ if (
+ tuya_uom is not None
+ and (uoms := DEVICE_CLASS_UNITS.get(device_class))
+ and (uom := uoms.get(tuya_uom) or uoms.get(tuya_uom.lower()))
+ ):
+ self._attr_native_unit_of_measurement = uom.unit
+ return
- # Unknown unit of measurement, device class should not be used.
- if uom is None:
- self._attr_device_class = None
- self._attr_suggested_unit_of_measurement = None
- return
+ if self.entity_description.native_unit_of_measurement is not None:
+ LOGGER.debug(
+ "Incompatible unit %s replaced by entity description unit %s "
+ "for device class %s in sensor entity %s; use a quirk "
+ "(https://github.com/home-assistant-libs/tuya-device-handlers)"
+ " to override",
+ tuya_uom,
+ self.entity_description.native_unit_of_measurement,
+ device_class,
+ self.unique_id,
+ )
+ return
- # Found unit of measurement, use the standardized Unit
- # Use the target conversion unit (if set)
- self._attr_native_unit_of_measurement = uom.unit
+ self._attr_native_unit_of_measurement = tuya_uom
+ self._attr_device_class = None
+ self._attr_suggested_unit_of_measurement = None
+ LOGGER.debug(
+ "Device class %s ignored for incompatible unit %s in sensor entity %s",
+ device_class,
+ tuya_uom,
+ self.unique_id,
+ )
@property
def native_value(self) -> StateType:
diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py
index 7f5750ba9c7698..c498e71f6a33d8 100644
--- a/homeassistant/components/voip/__init__.py
+++ b/homeassistant/components/voip/__init__.py
@@ -34,8 +34,6 @@
"async_unload_entry",
]
-type VoipConfigEntry = ConfigEntry[VoipStore]
-
@dataclass
class DomainData:
@@ -46,6 +44,17 @@ class DomainData:
devices: VoIPDevices
+@dataclass
+class VoipData:
+ """Voip Runtime Data."""
+
+ store: VoipStore
+ domain_data: DomainData
+
+
+type VoipConfigEntry = ConfigEntry[VoipData]
+
+
async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool:
"""Set up VoIP integration from a config entry."""
# Make sure there is a valid user ID for VoIP in the config entry
@@ -60,9 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool
entry, data={**entry.data, "user": voip_user.id}
)
- entry.runtime_data = VoipStore(hass, entry.entry_id)
sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT)
- devices = VoIPDevices(hass, entry)
+ store = VoipStore(hass, entry.entry_id)
+ devices = VoIPDevices(hass, entry, store)
await devices.async_setup()
transport, protocol = await _create_sip_server(
hass,
@@ -70,10 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool
sip_port,
)
_LOGGER.debug("Listening for VoIP calls on port %s", sip_port)
-
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- hass.data[DOMAIN] = DomainData(transport, protocol, devices)
+ entry.runtime_data = VoipData(store, DomainData(transport, protocol, devices))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -110,9 +116,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> boo
"""Unload VoIP."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
_LOGGER.debug("Shutting down VoIP server")
- data = hass.data.pop(DOMAIN)
- data.transport.close()
- await data.protocol.wait_closed()
+ entry.runtime_data.domain_data.transport.close()
+ await entry.runtime_data.domain_data.protocol.wait_closed()
_LOGGER.debug("VoIP server shut down successfully")
return unload_ok
@@ -132,4 +137,4 @@ async def async_remove_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> Non
):
await hass.auth.async_remove_user(user)
- await entry.runtime_data.async_remove()
+ await entry.runtime_data.store.async_remove()
diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py
index 31bb6979ef6715..60120cbdab0c13 100644
--- a/homeassistant/components/voip/assist_satellite.py
+++ b/homeassistant/components/voip/assist_satellite.py
@@ -1,5 +1,4 @@
"""Assist satellite entity for VoIP integration."""
-# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import asyncio
from datetime import timedelta
@@ -10,11 +9,11 @@
from pathlib import Path
import socket
import time
-from typing import TYPE_CHECKING, Any, Final
+from typing import Any, Final
import wave
from voip_utils import SIP_PORT, RtpDatagramProtocol
-from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint
+from voip_utils.sip import SipEndpoint, get_sip_endpoint
from homeassistant.components import intent, tts
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
@@ -27,11 +26,11 @@
)
from homeassistant.components.intent import TimerEventType, TimerInfo
from homeassistant.components.network import async_get_source_ip
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from . import VoipConfigEntry
from .const import (
CHANNELS,
CONF_SIP_PORT,
@@ -44,9 +43,6 @@
from .devices import VoIPDevice
from .entity import VoIPEntity
-if TYPE_CHECKING:
- from . import DomainData
-
_LOGGER = logging.getLogger(__name__)
_PIPELINE_TIMEOUT_SEC: Final = 30
@@ -73,11 +69,11 @@ class Tones(IntFlag):
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: VoipConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP Assist satellite entity."""
- domain_data: DomainData = hass.data[DOMAIN]
+ domain_data = config_entry.runtime_data.domain_data
@callback
def async_add_device(device: VoIPDevice) -> None:
@@ -110,7 +106,7 @@ def __init__(
self,
hass: HomeAssistant,
voip_device: VoIPDevice,
- config_entry: ConfigEntry,
+ config_entry: VoipConfigEntry,
tones=Tones.LISTENING | Tones.PROCESSING | Tones.ERROR,
) -> None:
"""Initialize an Assist satellite."""
@@ -269,7 +265,7 @@ async def _do_announce(
self._announcement = announcement
# Make the call
- sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol
+ sip_protocol = self.config_entry.runtime_data.domain_data.protocol
_LOGGER.debug("Outgoing call to contact %s", self.voip_device.contact)
call_info = sip_protocol.outgoing_call(
source=source_endpoint,
diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py
index 0c035d030041ec..68fc5092f6f662 100644
--- a/homeassistant/components/voip/binary_sensor.py
+++ b/homeassistant/components/voip/binary_sensor.py
@@ -6,28 +6,23 @@
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from . import VoipConfigEntry
from .const import DOMAIN
from .devices import VoIPDevice
from .entity import VoIPEntity
-if TYPE_CHECKING:
- from . import DomainData
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: VoipConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP binary sensor entities."""
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- domain_data: DomainData = hass.data[DOMAIN]
+ domain_data = config_entry.runtime_data.domain_data
@callback
def async_add_device(device: VoIPDevice) -> None:
diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py
index 088ca6a13ebcaa..d98d319917e7bd 100644
--- a/homeassistant/components/voip/devices.py
+++ b/homeassistant/components/voip/devices.py
@@ -3,18 +3,20 @@
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from voip_utils import CallInfo, VoipDatagramProtocol
from voip_utils.sip import SipEndpoint
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
from .store import DeviceContact, DeviceContacts, VoipStore
+if TYPE_CHECKING:
+ from . import VoipConfigEntry
+
_LOGGER = logging.getLogger(__name__)
@@ -78,13 +80,15 @@ def get_vad_sensitivity_entity_id(self, hass: HomeAssistant) -> str | None:
class VoIPDevices:
"""Class to store devices."""
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry: VoipConfigEntry, store: VoipStore
+ ) -> None:
"""Initialize VoIP devices."""
self.hass = hass
self.config_entry = config_entry
self._new_device_listeners: list[Callable[[VoIPDevice], None]] = []
self.devices: dict[str, VoIPDevice] = {}
- self.device_store: VoipStore = config_entry.runtime_data
+ self.device_store = store
async def async_setup(self) -> None:
"""Set up devices."""
diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py
index 71c796b7e5f4a6..b215daaa2cdbd6 100644
--- a/homeassistant/components/voip/select.py
+++ b/homeassistant/components/voip/select.py
@@ -1,32 +1,25 @@
"""Select entities for VoIP integration."""
-from typing import TYPE_CHECKING
-
from homeassistant.components.assist_pipeline import (
AssistPipelineSelect,
VadSensitivitySelect,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from . import VoipConfigEntry
from .const import DOMAIN
from .devices import VoIPDevice
from .entity import VoIPEntity
-if TYPE_CHECKING:
- from . import DomainData
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: VoipConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP switch entities."""
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- domain_data: DomainData = hass.data[DOMAIN]
+ domain_data = config_entry.runtime_data.domain_data
@callback
def async_add_device(device: VoIPDevice) -> None:
diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py
index a52efaac49b1ca..110049060232d6 100644
--- a/homeassistant/components/voip/switch.py
+++ b/homeassistant/components/voip/switch.py
@@ -1,31 +1,25 @@
"""VoIP switch entities."""
-from typing import TYPE_CHECKING, Any
+from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import restore_state
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
+from . import VoipConfigEntry
from .devices import VoIPDevice
from .entity import VoIPEntity
-if TYPE_CHECKING:
- from . import DomainData
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: VoipConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP switch entities."""
- # Uses legacy hass.data[DOMAIN] pattern
- # pylint: disable-next=home-assistant-use-runtime-data
- domain_data: DomainData = hass.data[DOMAIN]
+ domain_data = config_entry.runtime_data.domain_data
@callback
def async_add_device(device: VoIPDevice) -> None:
diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py
index 1b200f439b1e86..c1ccece20feb13 100644
--- a/homeassistant/components/weatherflow_cloud/coordinator.py
+++ b/homeassistant/components/weatherflow_cloud/coordinator.py
@@ -183,8 +183,10 @@ def _create_listen_message(self, device_id: int) -> RapidWindListenStartMessage:
"""Create rapid wind listen message."""
return RapidWindListenStartMessage(device_id=str(device_id))
- async def _handle_websocket_message(self, data: RapidWindWS) -> None:
+ async def _handle_websocket_message(self, data: RapidWindWS | None) -> None:
"""Handle rapid wind websocket data."""
+ if data is None:
+ return
device_id = data.device_id
station_id = self.device_to_station_map[device_id]
@@ -202,8 +204,12 @@ def _create_listen_message(self, device_id: int) -> ListenStartMessage:
"""Create observation listen message."""
return ListenStartMessage(device_id=str(device_id))
- async def _handle_websocket_message(self, data: ObservationTempestWS) -> None:
+ async def _handle_websocket_message(
+ self, data: ObservationTempestWS | None
+ ) -> None:
"""Handle observation websocket data."""
+ if data is None:
+ return
device_id = data.device_id
station_id = self.device_to_station_map[device_id]
diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py
index 2ab034f421c3ff..d94fadad50ffff 100644
--- a/homeassistant/components/wled/coordinator.py
+++ b/homeassistant/components/wled/coordinator.py
@@ -81,6 +81,15 @@ def has_main_light(self) -> bool:
self.data is not None and len(self.data.state.segments) > 1
)
+ @property
+ def segment_ids(self) -> set[int]:
+ """Return the set of segment IDs."""
+ return {
+ segment.segment_id
+ for segment in self.data.state.segments.values()
+ if segment.segment_id is not None
+ }
+
@callback
def _use_websocket(self) -> None:
"""Use WebSocket for updates, instead of polling."""
diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py
index c82884ebace6fe..701f765ebaa95e 100644
--- a/homeassistant/components/wled/light.py
+++ b/homeassistant/components/wled/light.py
@@ -16,6 +16,7 @@
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.group import IntegrationSpecificGroup
from .const import (
ATTR_CCT,
@@ -61,11 +62,23 @@ class WLEDMainLight(WLEDEntity, LightEntity):
_attr_translation_key = "main"
_attr_supported_features = LightEntityFeature.TRANSITION
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+ group: IntegrationSpecificGroup
def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None:
"""Initialize WLED main light."""
super().__init__(coordinator=coordinator)
self._attr_unique_id = coordinator.data.info.mac_address
+ self.group = IntegrationSpecificGroup(self, [])
+ self._update_group_member()
+
+ def _update_group_member(self) -> None:
+ """Update group members based on current segments."""
+ segment_unique_ids = [
+ f"{self.coordinator.data.info.mac_address}_{segment_id}"
+ for segment_id in sorted(self.coordinator.segment_ids)
+ ]
+ if segment_unique_ids != self.group.member_unique_ids:
+ self.group.member_unique_ids = segment_unique_ids
@property
def brightness(self) -> int | None:
@@ -104,6 +117,12 @@ async def async_turn_on(self, **kwargs: Any) -> None:
on=True, brightness=kwargs.get(ATTR_BRIGHTNESS), transition=transition
)
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Update attributes when the coordinator updates."""
+ self._update_group_member()
+ super()._handle_coordinator_update()
+
class WLEDSegmentLight(WLEDEntity, LightEntity):
"""Defines a WLED light based on a segment."""
@@ -280,11 +299,7 @@ def async_update_segments(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
- segment_ids = {
- light.segment_id
- for light in coordinator.data.state.segments.values()
- if light.segment_id is not None
- }
+ segment_ids = coordinator.segment_ids
new_entities: list[WLEDMainLight | WLEDSegmentLight] = []
# More than 1 segment now? No main? Add main controls
diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py
index 3964a0c7dd0c7e..bfc5dce12ed10f 100644
--- a/homeassistant/components/wled/number.py
+++ b/homeassistant/components/wled/number.py
@@ -127,16 +127,10 @@ def async_update_segments(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
- segment_ids = {
- segment.segment_id
- for segment in coordinator.data.state.segments.values()
- if segment.segment_id is not None
- }
-
new_entities: list[WLEDNumber] = []
# Process new segments, add them to Home Assistant
- for segment_id in segment_ids - current_ids:
+ for segment_id in coordinator.segment_ids - current_ids:
current_ids.add(segment_id)
new_entities.extend(
WLEDNumber(coordinator, segment_id, desc) for desc in NUMBERS
diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py
index 70eb8e5a901414..a2d21d8e3e704d 100644
--- a/homeassistant/components/wled/select.py
+++ b/homeassistant/components/wled/select.py
@@ -208,16 +208,10 @@ def async_update_segments(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
- segment_ids = {
- segment.segment_id
- for segment in coordinator.data.state.segments.values()
- if segment.segment_id is not None
- }
-
new_entities: list[WLEDPaletteSelect] = []
# Process new segments, add them to Home Assistant
- for segment_id in segment_ids - current_ids:
+ for segment_id in coordinator.segment_ids - current_ids:
current_ids.add(segment_id)
new_entities.append(WLEDPaletteSelect(coordinator, segment_id))
diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py
index 6b5b76147801a4..155732dad5f26f 100644
--- a/homeassistant/components/wled/switch.py
+++ b/homeassistant/components/wled/switch.py
@@ -245,16 +245,10 @@ def async_update_segments(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
- segment_ids = {
- segment.segment_id
- for segment in coordinator.data.state.segments.values()
- if segment.segment_id is not None
- }
-
new_entities: list[WLEDSegmentSwitch] = []
# Process new segments, add them to Home Assistant
- for segment_id in segment_ids - current_ids:
+ for segment_id in coordinator.segment_ids - current_ids:
current_ids.add(segment_id)
new_entities.extend(
WLEDSegmentSwitch(
diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py
index 2b1edc43afaf31..cdac13b37b1c28 100644
--- a/homeassistant/components/wyoming/conversation.py
+++ b/homeassistant/components/wyoming/conversation.py
@@ -1,7 +1,7 @@
"""Support for Wyoming intent recognition services."""
import logging
-from typing import Literal
+from typing import Any, Literal
from wyoming.asr import Transcript
from wyoming.client import AsyncTcpClient
@@ -11,8 +11,9 @@
from homeassistant.components import conversation
from homeassistant.const import MATCH_ALL
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import intent
+from homeassistant.core import HomeAssistant, State
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers import chat_session, intent, llm, template
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import ulid as ulid_util
@@ -105,6 +106,8 @@ async def async_process(
intent_response = intent.IntentResponse(language=user_input.language)
context = {"conversation_id": conversation_id}
+ if user_input.device_id:
+ context["device_id"] = user_input.device_id
if user_input.satellite_id:
context["satellite_id"] = user_input.satellite_id
@@ -117,68 +120,17 @@ async def async_process(
language=user_input.language,
).event()
)
-
- while True:
- event = await client.read_event()
- if event is None:
- _LOGGER.debug("Connection lost")
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- "Connection to service was lost",
- )
- return conversation.ConversationResult(
- response=intent_response,
- conversation_id=user_input.conversation_id,
- )
-
- if Intent.is_type(event.type):
- # Success
- recognized_intent = Intent.from_event(event)
- _LOGGER.debug("Recognized intent: %s", recognized_intent)
-
- intent_type = recognized_intent.name
- intent_slots = {
- e.name: {"value": e.value}
- for e in recognized_intent.entities
- }
- intent_response = await intent.async_handle(
- self.hass,
- DOMAIN,
- intent_type,
- intent_slots,
- text_input=user_input.text,
- language=user_input.language,
- satellite_id=user_input.satellite_id,
- device_id=user_input.device_id,
- )
-
- if (not intent_response.speech) and recognized_intent.text:
- intent_response.async_set_speech(recognized_intent.text)
-
- break
-
- if NotRecognized.is_type(event.type):
- not_recognized = NotRecognized.from_event(event)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.NO_INTENT_MATCH,
- not_recognized.text or "",
- )
- break
-
- if Handled.is_type(event.type):
- # Success
- handled = Handled.from_event(event)
- intent_response.async_set_speech(handled.text or "")
- break
-
- if NotHandled.is_type(event.type):
- not_handled = NotHandled.from_event(event)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
- not_handled.text or "",
- )
- break
-
+ with (
+ chat_session.async_get_chat_session(
+ self.hass, user_input.conversation_id
+ ) as session,
+ conversation.async_get_chat_log(
+ self.hass, session, user_input
+ ) as chat_log,
+ ):
+ intent_response = await self._async_process(
+ user_input, client, chat_log, intent_response
+ )
except (OSError, WyomingError) as err:
_LOGGER.exception("Unexpected error while communicating with service")
intent_response.async_set_error(
@@ -204,3 +156,149 @@ async def async_process(
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
+
+ async def _async_process(
+ self,
+ user_input: conversation.ConversationInput,
+ client: AsyncTcpClient,
+ chat_log: conversation.ChatLog,
+ intent_response: intent.IntentResponse,
+ ) -> intent.IntentResponse:
+ """Process a sentence into an intent response."""
+ while True:
+ event = await client.read_event()
+ if event is None:
+ raise WyomingError("Connection lost")
+
+ if Intent.is_type(event.type):
+ # Success
+ recognized_intent = Intent.from_event(event)
+ _LOGGER.debug("Recognized intent: %s", recognized_intent)
+
+ intent_type = recognized_intent.name
+ intent_slots = {
+ e.name: {"value": e.value} for e in recognized_intent.entities
+ }
+
+ # Add to trace and chat log
+ conversation.async_conversation_trace_append(
+ conversation.ConversationTraceEventType.TOOL_CALL,
+ {
+ "intent_name": intent_type,
+ "slots": intent_slots,
+ },
+ )
+ tool_input = llm.ToolInput(
+ tool_name=intent_type,
+ tool_args=intent_slots,
+ external=True,
+ )
+ chat_log.async_add_assistant_content_without_tools(
+ conversation.AssistantContent(
+ agent_id=user_input.agent_id,
+ content=None,
+ tool_calls=[tool_input],
+ )
+ )
+ intent_response = await intent.async_handle(
+ self.hass,
+ DOMAIN,
+ intent_type,
+ intent_slots,
+ text_input=user_input.text,
+ language=user_input.language,
+ satellite_id=user_input.satellite_id,
+ device_id=user_input.device_id,
+ )
+
+ if (not intent_response.speech) and recognized_intent.text:
+ response_text = recognized_intent.text
+ if template.is_template_string(response_text):
+ # Render text as a template
+ response_text = self._render_speech_template(
+ response_text, intent_response, intent_slots
+ )
+
+ intent_response.async_set_speech(response_text)
+
+ break
+
+ if NotRecognized.is_type(event.type):
+ # Intent was not recognized
+ not_recognized = NotRecognized.from_event(event)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.NO_INTENT_MATCH,
+ not_recognized.text or "",
+ )
+ break
+
+ if Handled.is_type(event.type):
+ # Success
+ handled = Handled.from_event(event)
+ intent_response.async_set_speech(handled.text or "")
+ break
+
+ if NotHandled.is_type(event.type):
+ # Command was not handled
+ not_handled = NotHandled.from_event(event)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
+ not_handled.text or "",
+ )
+ break
+
+ return intent_response
+
+ def _render_speech_template(
+ self,
+ response_text: str,
+ intent_response: intent.IntentResponse,
+ intent_slots: dict[str, Any],
+ ) -> str:
+ """Render speech template with similar behavior to the default agent."""
+ state1: State | None = None
+ if intent_response.matched_states:
+ state1 = intent_response.matched_states[0]
+ elif intent_response.unmatched_states:
+ state1 = intent_response.unmatched_states[0]
+
+ # Render response template
+ speech_slots = {name: value["value"] for name, value in intent_slots.items()}
+ speech_slots.update(intent_response.speech_slots)
+
+ response_template = template.Template(response_text, self.hass)
+ try:
+ speech = response_template.async_render(
+ {
+ # Slots from intent recognizer and response
+ "slots": speech_slots,
+ # First matched or unmatched state
+ "state": (
+ template.TemplateState(self.hass, state1)
+ if state1 is not None
+ else None
+ ),
+ "query": {
+ # Entity states that matched the query (e.g, "on")
+ "matched": [
+ template.TemplateState(self.hass, state)
+ for state in intent_response.matched_states
+ ],
+ # Entity states that did not match the query
+ "unmatched": [
+ template.TemplateState(self.hass, state)
+ for state in intent_response.unmatched_states
+ ],
+ },
+ }
+ )
+ except TemplateError:
+ _LOGGER.exception("Unexpected error while rendering response")
+ raise
+
+ # Normalize whitespace
+ if speech is not None:
+ speech = str(speech)
+ speech = " ".join(speech.strip().split())
+
+ return speech
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index ce3003f37fd12c..58853b39696942 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -694,7 +694,7 @@ def slugify(value: Any) -> str:
def string(value: Any) -> str:
- """Coerce value to string, except for None."""
+ """Coerce value to string, except for None, list or dict."""
if value is None:
raise vol.Invalid("string value is None")
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index fe1c3d86a5c559..5fa5010ca44d2c 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -61,7 +61,7 @@ pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.3
PyYAML==6.0.3
-requests==2.33.1
+requests==2.34.2
securetar==2026.4.1
serialx==1.8.0
SQLAlchemy==2.0.49
@@ -70,7 +70,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
-uv==0.11.13
+uv==0.11.14
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
diff --git a/pyproject.toml b/pyproject.toml
index 176030366d0524..b717b1141f28d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,7 +66,7 @@ dependencies = [
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
"PyYAML==6.0.3",
- "requests==2.33.1",
+ "requests==2.34.2",
"securetar==2026.4.1",
"SQLAlchemy==2.0.49",
"standard-aifc==3.13.0",
@@ -74,7 +74,7 @@ dependencies = [
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.0",
"urllib3>=2.0",
- "uv==0.11.13",
+ "uv==0.11.14",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
@@ -648,7 +648,7 @@ exclude_lines = [
]
[tool.ruff]
-required-version = ">=0.15.12"
+required-version = ">=0.15.13"
[tool.ruff.lint]
select = [
diff --git a/requirements.txt b/requirements.txt
index e68a2b340837e3..46df9fcc5fab22 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -46,7 +46,7 @@ pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.3
PyYAML==6.0.3
-requests==2.33.1
+requests==2.34.2
rf-protocols==3.2.0
securetar==2026.4.1
SQLAlchemy==2.0.49
@@ -55,7 +55,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
-uv==0.11.13
+uv==0.11.14
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
diff --git a/requirements_all.txt b/requirements_all.txt
index 6bf89cfde2309f..33513cd0788b56 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -297,7 +297,7 @@ aiohue==4.8.1
aioimaplib==2.0.1
# homeassistant.components.immich
-aioimmich==0.14.0
+aioimmich==0.14.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
diff --git a/requirements_test.txt b/requirements_test.txt
index 8fdb147e4b790a..8e0e9b51b7b90f 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -36,7 +36,7 @@ pytest-xdist==3.8.0
pytest==9.0.3
requests-mock==1.12.1
respx==0.23.1
-syrupy==5.1.0
+syrupy==5.2.0
tqdm==4.67.1
types-aiofiles==24.1.0.20250822
types-atomicwrites==1.4.5.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 013a12935a36c4..67f5546f8532d0 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -285,7 +285,7 @@ aiohue==4.8.1
aioimaplib==2.0.1
# homeassistant.components.immich
-aioimmich==0.14.0
+aioimmich==0.14.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index cd8aec77ad9405..649e45c9ea315d 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.12
+ruff==0.15.13
yamllint==1.38.0
zizmor==1.24.1
diff --git a/tests/components/avea/test_light.py b/tests/components/avea/test_light.py
index ad6527a31bf553..3a290fd3c15496 100644
--- a/tests/components/avea/test_light.py
+++ b/tests/components/avea/test_light.py
@@ -108,6 +108,40 @@ async def test_turn_on_and_off(
bulb.set_brightness.assert_called_with(0)
+async def test_turn_on_restores_last_brightness(
+ hass: HomeAssistant,
+ setup_integration: MagicMock,
+) -> None:
+ """Test turning the light on restores the last brightness."""
+ bulb = setup_integration
+
+ await hass.services.async_call(
+ "light",
+ "turn_on",
+ {ATTR_ENTITY_ID: "light.bedroom", ATTR_BRIGHTNESS: 128},
+ blocking=True,
+ )
+ bulb.set_brightness.assert_called_with(2056)
+
+ bulb.set_brightness.reset_mock()
+ await hass.services.async_call(
+ "light",
+ "turn_off",
+ {ATTR_ENTITY_ID: "light.bedroom"},
+ blocking=True,
+ )
+ bulb.set_brightness.assert_called_with(0)
+
+ bulb.set_brightness.reset_mock()
+ await hass.services.async_call(
+ "light",
+ "turn_on",
+ {ATTR_ENTITY_ID: "light.bedroom"},
+ blocking=True,
+ )
+ bulb.set_brightness.assert_called_with(2056)
+
+
async def test_update_state(
hass: HomeAssistant, setup_integration: MagicMock, freezer: FrozenDateTimeFactory
) -> None:
diff --git a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr
index b0776f12d0888f..bcf2ba2159d25c 100644
--- a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr
+++ b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr
@@ -369,7 +369,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'unknown',
+ 'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.c3_stop_100_next_departure_3_direction-entry]
@@ -419,7 +419,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'unknown',
+ 'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.c3_stop_100_next_departure_3_type-entry]
@@ -479,7 +479,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'unknown',
+ 'state': 'unavailable',
})
# ---
# name: test_velov_all_entities[sensor.velo_v_1001_available_bikes-entry]
diff --git a/tests/components/data_grand_lyon/test_sensor.py b/tests/components/data_grand_lyon/test_sensor.py
index 76f2716dcf2417..cfb6706af47b9f 100644
--- a/tests/components/data_grand_lyon/test_sensor.py
+++ b/tests/components/data_grand_lyon/test_sensor.py
@@ -22,7 +22,12 @@
ConfigEntryState,
ConfigSubentryData,
)
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.const import (
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ STATE_UNAVAILABLE,
+ Platform,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -89,7 +94,7 @@ async def test_stop_sensor_no_data(
mock_config_entry: MockConfigEntry,
mock_tcl_client: AsyncMock,
) -> None:
- """Test that sensors with no departure data return unknown."""
+ """Test that sensors are unavailable when no departure data is found."""
mock_tcl_client.get_tcl_passages.return_value = []
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -97,7 +102,7 @@ async def test_stop_sensor_no_data(
state = hass.states.get("sensor.c3_stop_100_next_departure_1")
assert state is not None
- assert state.state == "unknown"
+ assert state.state == STATE_UNAVAILABLE
async def test_stop_sensor_aware_datetime_passthrough(
@@ -135,15 +140,13 @@ async def test_coordinator_stop_fetch_error(
mock_config_entry: MockConfigEntry,
mock_tcl_client: AsyncMock,
) -> None:
- """Test coordinator handles stop fetch errors gracefully."""
+ """Test coordinator raises UpdateFailed on stop fetch error."""
mock_tcl_client.get_tcl_passages.side_effect = ClientConnectionError("API down")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.c3_stop_100_next_departure_1")
- assert state is not None
- assert state.state == "unknown"
+ assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_coordinator_stop_http_error(
@@ -151,7 +154,7 @@ async def test_coordinator_stop_http_error(
mock_config_entry: MockConfigEntry,
mock_tcl_client: AsyncMock,
) -> None:
- """Test coordinator handles non-auth HTTP errors for stops."""
+ """Test coordinator raises UpdateFailed on non-auth HTTP errors for stops."""
mock_tcl_client.get_tcl_passages.side_effect = ClientResponseError(
Mock(), (), status=500
)
@@ -159,9 +162,7 @@ async def test_coordinator_stop_http_error(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.c3_stop_100_next_departure_1")
- assert state is not None
- assert state.state == "unknown"
+ assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_coordinator_velov_auth_error(
@@ -188,7 +189,7 @@ async def test_coordinator_velov_http_error(
mock_velov_config_entry: MockConfigEntry,
mock_tcl_client: AsyncMock,
) -> None:
- """Test coordinator handles non-auth HTTP errors for Vélo'v."""
+ """Test coordinator raises UpdateFailed on non-auth HTTP errors for Vélo'v."""
mock_tcl_client.get_velov_stations.side_effect = ClientResponseError(
Mock(), (), status=500
)
@@ -196,9 +197,7 @@ async def test_coordinator_velov_http_error(
await hass.config_entries.async_setup(mock_velov_config_entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.velo_v_1001_available_bikes")
- assert state is not None
- assert state.state == "unavailable"
+ assert mock_velov_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_coordinator_all_fetch_errors(
@@ -294,7 +293,7 @@ async def test_velov_sensor_no_data(
state = hass.states.get("sensor.velo_v_1001_available_bikes")
assert state is not None
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
async def test_coordinator_velov_fetch_error(
@@ -302,22 +301,20 @@ async def test_coordinator_velov_fetch_error(
mock_velov_config_entry: MockConfigEntry,
mock_tcl_client: AsyncMock,
) -> None:
- """Test coordinator handles Vélo'v fetch errors gracefully."""
+ """Test coordinator raises UpdateFailed on Vélo'v fetch error."""
mock_tcl_client.get_velov_stations.side_effect = ClientConnectionError("API down")
mock_velov_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_velov_config_entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.velo_v_1001_available_bikes")
- assert state is not None
- assert state.state == "unavailable"
+ assert mock_velov_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_coordinator_mixed_partial_failure(
hass: HomeAssistant,
mock_tcl_client: AsyncMock,
) -> None:
- """Test coordinator succeeds when stop fails but Vélo'v succeeds."""
+ """Test that when one coordinator fails at setup, the entry retries."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Data Grand Lyon",
@@ -346,6 +343,4 @@ async def test_coordinator_mixed_partial_failure(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.velo_v_1001_available_bikes")
- assert state is not None
- assert state.state != "unavailable"
+ assert entry.state is ConfigEntryState.SETUP_RETRY
diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py
index 1181c391ca2f52..db32a228958187 100644
--- a/tests/components/dnsip/test_init.py
+++ b/tests/components/dnsip/test_init.py
@@ -1,7 +1,12 @@
"""Test for DNS IP integration Init."""
+import asyncio
from unittest.mock import patch
+from aiodns.error import DNSError
+from pycares import AresError
+import pytest
+
from homeassistant.components.dnsip.const import (
CONF_HOSTNAME,
CONF_IPV4,
@@ -44,7 +49,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None:
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver",
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
return_value=RetrieveDNS(),
):
await hass.config_entries.async_setup(entry.entry_id)
@@ -82,8 +87,8 @@ async def test_port_migration(
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=RetrieveDNS(),
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ side_effect=[RetrieveDNS(), RetrieveDNS()],
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -123,8 +128,8 @@ async def test_remove_unique_id_migration(
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=RetrieveDNS(),
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ side_effect=[RetrieveDNS(), RetrieveDNS()],
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -160,7 +165,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
return_value=RetrieveDNS(),
):
await hass.config_entries.async_setup(entry.entry_id)
@@ -168,3 +173,119 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
entry = hass.config_entries.async_get_entry(entry.entry_id)
assert entry.state is ConfigEntryState.MIGRATION_ERROR
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ TimeoutError(),
+ DNSError(),
+ AresError(),
+ asyncio.CancelledError(),
+ ],
+)
+async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None:
+ """Test setup raises ConfigEntryNotReady when DNS lookup fails."""
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_HOSTNAME: "home-assistant.io",
+ CONF_NAME: "home-assistant.io",
+ CONF_IPV4: True,
+ CONF_IPV6: False,
+ },
+ options={
+ CONF_RESOLVER: "208.67.222.222",
+ CONF_RESOLVER_IPV6: "2620:119:53::53",
+ CONF_PORT: 53,
+ CONF_PORT_IPV6: 53,
+ },
+ entry_id="1",
+ unique_id="home-assistant.io",
+ )
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ return_value=RetrieveDNS(error=error),
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.SETUP_RETRY
+
+
+async def test_setup_ipv6_only(hass: HomeAssistant) -> None:
+ """Test setup with only IPv6 enabled creates only the IPv6 entity."""
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_HOSTNAME: "home-assistant.io",
+ CONF_NAME: "home-assistant.io",
+ CONF_IPV4: False,
+ CONF_IPV6: True,
+ },
+ options={
+ CONF_RESOLVER: "208.67.222.222",
+ CONF_RESOLVER_IPV6: "2620:119:53::53",
+ CONF_PORT: 53,
+ CONF_PORT_IPV6: 53,
+ },
+ entry_id="1",
+ unique_id="home-assistant.io",
+ )
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ return_value=RetrieveDNS(),
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.LOADED
+ assert hass.states.get("sensor.home_assistant_io_ipv6") is not None
+ assert hass.states.get("sensor.home_assistant_io") is None
+
+
+async def test_setup_dns_timeout(hass: HomeAssistant) -> None:
+ """Test setup raises ConfigEntryNotReady when DNS lookup times out."""
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_HOSTNAME: "home-assistant.io",
+ CONF_NAME: "home-assistant.io",
+ CONF_IPV4: True,
+ CONF_IPV6: False,
+ },
+ options={
+ CONF_RESOLVER: "208.67.222.222",
+ CONF_RESOLVER_IPV6: "2620:119:53::53",
+ CONF_PORT: 53,
+ CONF_PORT_IPV6: 53,
+ },
+ entry_id="1",
+ unique_id="home-assistant.io",
+ )
+ entry.add_to_hass(hass)
+
+ with (
+ patch(
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ return_value=RetrieveDNS(),
+ ),
+ patch(
+ "homeassistant.components.dnsip.asyncio.timeout",
+ side_effect=TimeoutError(),
+ ),
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.SETUP_RETRY
diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py
index b7dae634765112..ef8f7809dd6416 100644
--- a/tests/components/dnsip/test_sensor.py
+++ b/tests/components/dnsip/test_sensor.py
@@ -48,8 +48,8 @@ async def test_sensor(hass: HomeAssistant) -> None:
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=RetrieveDNS(),
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ side_effect=[RetrieveDNS(), RetrieveDNS()],
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -91,8 +91,8 @@ async def test_legacy_sensor(hass: HomeAssistant) -> None:
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=RetrieveDNS(),
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ side_effect=[RetrieveDNS(), RetrieveDNS()],
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -135,10 +135,10 @@ async def test_sensor_no_response(
)
entry.add_to_hass(hass)
- dns_mock = RetrieveDNS()
+ dns_mock_ipv4 = RetrieveDNS()
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=dns_mock,
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ return_value=dns_mock_ipv4,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -147,10 +147,10 @@ async def test_sensor_no_response(
assert state.state == "1.1.1.1"
- dns_mock.error = DNSError()
+ dns_mock_ipv4.error = DNSError()
with patch(
"homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=dns_mock,
+ return_value=dns_mock_ipv4,
):
freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds))
async_fire_time_changed(hass)
@@ -158,7 +158,6 @@ async def test_sensor_no_response(
async_fire_time_changed(hass)
await hass.async_block_till_done()
- # Allows 2 retries before going unavailable
state = hass.states.get("sensor.home_assistant_io")
assert state.state == "1.1.1.1"
assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"]
@@ -195,10 +194,10 @@ async def test_sensor_timeout(
)
entry.add_to_hass(hass)
- dns_mock = RetrieveDNS()
+ dns_mock_ipv4 = RetrieveDNS()
with patch(
- "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=dns_mock,
+ "homeassistant.components.dnsip.aiodns.DNSResolver",
+ return_value=dns_mock_ipv4,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -210,7 +209,7 @@ async def test_sensor_timeout(
with (
patch(
"homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
- return_value=dns_mock,
+ return_value=dns_mock_ipv4,
),
patch(
"homeassistant.components.dnsip.sensor.asyncio.timeout",
@@ -221,7 +220,6 @@ async def test_sensor_timeout(
async_fire_time_changed(hass)
await hass.async_block_till_done()
- # Allows 2 retries before going unavailable
state = hass.states.get("sensor.home_assistant_io")
assert state.state == "1.1.1.1"
assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"]
diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py
index a92b681ec24369..3609e811e526c2 100644
--- a/tests/components/enphase_envoy/test_init.py
+++ b/tests/components/enphase_envoy/test_init.py
@@ -2,7 +2,7 @@
from datetime import timedelta
import logging
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, call, patch
from freezegun.api import FrozenDateTimeFactory
from jwt import encode
@@ -13,8 +13,10 @@
from homeassistant.components.enphase_envoy import DOMAIN
from homeassistant.components.enphase_envoy.const import (
+ OPERATIONAL_RETRY_TIMEOUT,
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES,
OPTION_DISABLE_KEEP_ALIVE,
+ SETUP_RETRY_TIMEOUT,
Platform,
)
from homeassistant.components.enphase_envoy.coordinator import (
@@ -627,3 +629,42 @@ async def test_coordinator_interface_information_mac_also_in_other_device(
"00:11:22:33:44:55",
)
}
+
+
+@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00")
+async def test_retry_timeout_settings(
+ hass: HomeAssistant,
+ mock_envoy: AsyncMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test coordinator with token provided from config."""
+ token = encode(
+ payload={"name": "envoy", "exp": 1907837780},
+ key="secret",
+ algorithm="HS256",
+ )
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="45a36e55aaddb2007c5f6602e0c38e72",
+ title="Envoy 1234",
+ unique_id="1234",
+ data={
+ CONF_HOST: "1.1.1.1",
+ CONF_NAME: "Envoy 1234",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ CONF_TOKEN: token,
+ },
+ )
+ mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234")
+ await setup_integration(hass, entry)
+
+ assert (entity_state := hass.states.get("sensor.inverter_1"))
+ assert entity_state.state == "116"
+
+ assert mock_envoy.mock_calls[0] == call.set_retry_policy(
+ max_delay=SETUP_RETRY_TIMEOUT
+ )
+ assert mock_envoy.mock_calls[-1] == call.set_retry_policy(
+ max_delay=OPERATIONAL_RETRY_TIMEOUT
+ )
diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py
index a5950abd98d65d..4415f563ffd904 100644
--- a/tests/components/fritz/const.py
+++ b/tests/components/fritz/const.py
@@ -907,84 +907,88 @@
],
}
+MOCK_HOST_PRINTER = {
+ "Index": 1,
+ "IPAddress": MOCK_IPS["printer"],
+ "MACAddress": "AA:BB:CC:00:11:22",
+ "Active": True,
+ "HostName": "printer",
+ "InterfaceType": "Ethernet",
+ "X_AVM-DE_Port": 1,
+ "X_AVM-DE_Speed": 1000,
+ "X_AVM-DE_UpdateAvailable": False,
+ "X_AVM-DE_UpdateSuccessful": "unknown",
+ "X_AVM-DE_InfoURL": None,
+ "X_AVM-DE_MACAddressList": None,
+ "X_AVM-DE_Model": None,
+ "X_AVM-DE_URL": f"http://{MOCK_IPS['printer']}",
+ "X_AVM-DE_Guest": False,
+ "X_AVM-DE_RequestClient": "0",
+ "X_AVM-DE_VPN": False,
+ "X_AVM-DE_WANAccess": "granted",
+ "X_AVM-DE_Disallow": False,
+ "X_AVM-DE_IsMeshable": "0",
+ "X_AVM-DE_Priority": "0",
+ "X_AVM-DE_FriendlyName": "printer",
+ "X_AVM-DE_FriendlyNameIsWriteable": "1",
+}
+
+MOCK_HOST_FRITZBOX = {
+ "Index": 2,
+ "IPAddress": MOCK_IPS["fritz.box"],
+ "MACAddress": MOCK_MESH_MASTER_MAC,
+ "Active": True,
+ "HostName": "fritz.box",
+ "InterfaceType": None,
+ "X_AVM-DE_Port": 0,
+ "X_AVM-DE_Speed": 0,
+ "X_AVM-DE_UpdateAvailable": False,
+ "X_AVM-DE_UpdateSuccessful": "unknown",
+ "X_AVM-DE_InfoURL": None,
+ "X_AVM-DE_MACAddressList": f"{MOCK_MESH_MASTER_MAC},{MOCK_MESH_MASTER_WIFI1_MAC}",
+ "X_AVM-DE_Model": None,
+ "X_AVM-DE_URL": f"http://{MOCK_IPS['fritz.box']}",
+ "X_AVM-DE_Guest": False,
+ "X_AVM-DE_RequestClient": "0",
+ "X_AVM-DE_VPN": False,
+ "X_AVM-DE_WANAccess": "granted",
+ "X_AVM-DE_Disallow": False,
+ "X_AVM-DE_IsMeshable": "1",
+ "X_AVM-DE_Priority": "0",
+ "X_AVM-DE_FriendlyName": "fritz.box",
+ "X_AVM-DE_FriendlyNameIsWriteable": "0",
+}
+
+MOCK_HOST_SERVER = {
+ "Index": 3,
+ "IPAddress": MOCK_IPS["server"],
+ "MACAddress": "AA:BB:CC:33:44:55",
+ "Active": True,
+ "HostName": "server",
+ "InterfaceType": "Ethernet",
+ "X_AVM-DE_Port": 1,
+ "X_AVM-DE_Speed": 1000,
+ "X_AVM-DE_UpdateAvailable": False,
+ "X_AVM-DE_UpdateSuccessful": "unknown",
+ "X_AVM-DE_InfoURL": None,
+ "X_AVM-DE_MACAddressList": None,
+ "X_AVM-DE_Model": None,
+ "X_AVM-DE_URL": f"http://{MOCK_IPS['server']}",
+ "X_AVM-DE_Guest": False,
+ "X_AVM-DE_RequestClient": "0",
+ "X_AVM-DE_VPN": False,
+ "X_AVM-DE_WANAccess": "granted",
+ "X_AVM-DE_Disallow": False,
+ "X_AVM-DE_IsMeshable": "0",
+ "X_AVM-DE_Priority": "0",
+ "X_AVM-DE_FriendlyName": "server",
+ "X_AVM-DE_FriendlyNameIsWriteable": "1",
+}
+
MOCK_HOST_ATTRIBUTES_DATA = [
- {
- "Index": 1,
- "IPAddress": MOCK_IPS["printer"],
- "MACAddress": "AA:BB:CC:00:11:22",
- "Active": True,
- "HostName": "printer",
- "InterfaceType": "Ethernet",
- "X_AVM-DE_Port": 1,
- "X_AVM-DE_Speed": 1000,
- "X_AVM-DE_UpdateAvailable": False,
- "X_AVM-DE_UpdateSuccessful": "unknown",
- "X_AVM-DE_InfoURL": None,
- "X_AVM-DE_MACAddressList": None,
- "X_AVM-DE_Model": None,
- "X_AVM-DE_URL": f"http://{MOCK_IPS['printer']}",
- "X_AVM-DE_Guest": False,
- "X_AVM-DE_RequestClient": "0",
- "X_AVM-DE_VPN": False,
- "X_AVM-DE_WANAccess": "granted",
- "X_AVM-DE_Disallow": False,
- "X_AVM-DE_IsMeshable": "0",
- "X_AVM-DE_Priority": "0",
- "X_AVM-DE_FriendlyName": "printer",
- "X_AVM-DE_FriendlyNameIsWriteable": "1",
- },
- {
- "Index": 2,
- "IPAddress": MOCK_IPS["fritz.box"],
- "MACAddress": MOCK_MESH_MASTER_MAC,
- "Active": True,
- "HostName": "fritz.box",
- "InterfaceType": None,
- "X_AVM-DE_Port": 0,
- "X_AVM-DE_Speed": 0,
- "X_AVM-DE_UpdateAvailable": False,
- "X_AVM-DE_UpdateSuccessful": "unknown",
- "X_AVM-DE_InfoURL": None,
- "X_AVM-DE_MACAddressList": (
- f"{MOCK_MESH_MASTER_MAC},{MOCK_MESH_MASTER_WIFI1_MAC}"
- ),
- "X_AVM-DE_Model": None,
- "X_AVM-DE_URL": f"http://{MOCK_IPS['fritz.box']}",
- "X_AVM-DE_Guest": False,
- "X_AVM-DE_RequestClient": "0",
- "X_AVM-DE_VPN": False,
- "X_AVM-DE_WANAccess": "granted",
- "X_AVM-DE_Disallow": False,
- "X_AVM-DE_IsMeshable": "1",
- "X_AVM-DE_Priority": "0",
- "X_AVM-DE_FriendlyName": "fritz.box",
- "X_AVM-DE_FriendlyNameIsWriteable": "0",
- },
- {
- "Index": 3,
- "IPAddress": MOCK_IPS["server"],
- "MACAddress": "AA:BB:CC:33:44:55",
- "Active": True,
- "HostName": "server",
- "InterfaceType": "Ethernet",
- "X_AVM-DE_Port": 1,
- "X_AVM-DE_Speed": 1000,
- "X_AVM-DE_UpdateAvailable": False,
- "X_AVM-DE_UpdateSuccessful": "unknown",
- "X_AVM-DE_InfoURL": None,
- "X_AVM-DE_MACAddressList": None,
- "X_AVM-DE_Model": None,
- "X_AVM-DE_URL": f"http://{MOCK_IPS['server']}",
- "X_AVM-DE_Guest": False,
- "X_AVM-DE_RequestClient": "0",
- "X_AVM-DE_VPN": False,
- "X_AVM-DE_WANAccess": "granted",
- "X_AVM-DE_Disallow": False,
- "X_AVM-DE_IsMeshable": "0",
- "X_AVM-DE_Priority": "0",
- "X_AVM-DE_FriendlyName": "server",
- "X_AVM-DE_FriendlyNameIsWriteable": "1",
- },
+ MOCK_HOST_PRINTER,
+ MOCK_HOST_FRITZBOX,
+ MOCK_HOST_SERVER,
]
MOCK_CALL_DEFLECTION_DATA = {
diff --git a/tests/components/fritz/test_coordinator.py b/tests/components/fritz/test_coordinator.py
index 8bea17a0ac4d95..630622cf0b252a 100644
--- a/tests/components/fritz/test_coordinator.py
+++ b/tests/components/fritz/test_coordinator.py
@@ -5,6 +5,7 @@
from typing import cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
+from freezegun.api import FrozenDateTimeFactory
from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
@@ -18,12 +19,15 @@
DEFAULT_CONF_FEATURE_DEVICE_TRACKING,
DEFAULT_SSL,
DOMAIN,
+ SCAN_INTERVAL,
)
from homeassistant.components.fritz.coordinator import (
+ FRITZ_DATA_KEY,
AvmWrapper,
ClassSetupMissing,
FritzBoxTools,
FritzConnectionCached,
+ FritzData,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
@@ -35,12 +39,18 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import FritzConnectionMock, FritzServiceMock
-from .const import MOCK_SERIAL_NUMBER, MOCK_STATUS_DEVICE_INFO_DATA, MOCK_USER_DATA
+from .const import (
+ MOCK_HOST_FRITZBOX,
+ MOCK_HOST_PRINTER,
+ MOCK_SERIAL_NUMBER,
+ MOCK_STATUS_DEVICE_INFO_DATA,
+ MOCK_USER_DATA,
+)
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture(name="mock_config_entry")
@@ -82,6 +92,8 @@ async def fixture_fritz_tools(
port=mock_config_entry.data["port"],
)
+ hass.data.setdefault(FRITZ_DATA_KEY, FritzData())
+
await coordinator.async_setup()
return coordinator
@@ -567,6 +579,70 @@ async def test_avmwrapper_passthrough_methods(
assert await wrapper.async_wake_on_lan("AA:BB:CC:DD:EE:FF") == {}
+async def test_async_trigger_cleanup(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+ freezer: FrozenDateTimeFactory,
+ fc_class_mock,
+ fh_class_mock,
+ fs_class_mock,
+) -> None:
+ """Test the cleanup of orphan devices."""
+
+ fh_class_mock.get_hosts_attributes.return_value = [
+ MOCK_HOST_PRINTER,
+ MOCK_HOST_FRITZBOX,
+ ]
+
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done(wait_background_tasks=True)
+ assert entry.state is ConfigEntryState.LOADED
+
+ # Verify the printer is registered as tracked device
+ assert device_registry.async_get_device(
+ connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:00:11:22")}
+ )
+ assert entity_registry.async_get("device_tracker.printer")
+ assert entity_registry.async_get("switch.printer_internet_access")
+
+ # remove printer from host list
+ fh_class_mock.get_hosts_attributes.return_value = [MOCK_HOST_FRITZBOX]
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done(wait_background_tasks=True)
+
+ # Verify the printer was removed from tracked devices
+ assert (
+ device_registry.async_get_device(
+ connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:00:11:22")}
+ )
+ is None
+ )
+ assert entity_registry.async_get("device_tracker.printer") is None
+ assert entity_registry.async_get("switch.printer_internet_access") is None
+
+ # add printer again
+ fh_class_mock.get_hosts_attributes.return_value = [
+ MOCK_HOST_PRINTER,
+ MOCK_HOST_FRITZBOX,
+ ]
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done(wait_background_tasks=True)
+
+ # Verify the printer is registered again as tracked device
+ assert device_registry.async_get_device(
+ connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:00:11:22")}
+ )
+ assert entity_registry.async_get("device_tracker.printer")
+ assert entity_registry.async_get("switch.printer_internet_access")
+
+
async def test_async_trigger_cleanup_preserves_fritz_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/matter/snapshots/test_siren.ambr b/tests/components/matter/snapshots/test_siren.ambr
new file mode 100644
index 00000000000000..57277b49c845f6
--- /dev/null
+++ b/tests/components/matter/snapshots/test_siren.ambr
@@ -0,0 +1,52 @@
+# serializer version: 1
+# name: test_sirens[heiman_smoke_detector][siren.smoke_sensor_siren-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'siren',
+ 'entity_category': None,
+ 'entity_id': 'siren.smoke_sensor_siren',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Siren',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Siren',
+ 'platform': 'matter',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': ,
+ 'translation_key': 'siren',
+ 'unique_id': '00000000000004D2-000000000000000B-MatterNodeDevice-1-HeimanSiren-302775297-20',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sirens[heiman_smoke_detector][siren.smoke_sensor_siren-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Smoke sensor Siren',
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'siren.smoke_sensor_siren',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/matter/test_siren.py b/tests/components/matter/test_siren.py
new file mode 100644
index 00000000000000..74a4156bbf84b1
--- /dev/null
+++ b/tests/components/matter/test_siren.py
@@ -0,0 +1,121 @@
+"""Test Matter siren entities."""
+
+from unittest.mock import MagicMock, call
+
+from matter_server.client.models.node import MatterNode
+from matter_server.common.custom_clusters import HeimanCluster
+from matter_server.common.helpers.util import create_attribute_path_from_attribute
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from .common import (
+ set_node_attribute,
+ snapshot_matter_entities,
+ trigger_subscription_callback,
+)
+
+
+@pytest.mark.usefixtures("matter_devices")
+async def test_sirens(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test sirens."""
+ snapshot_matter_entities(hass, entity_registry, snapshot, Platform.SIREN)
+
+
+@pytest.mark.parametrize("node_fixture", ["heiman_smoke_detector"])
+async def test_turn_on(
+ hass: HomeAssistant,
+ matter_client: MagicMock,
+ matter_node: MatterNode,
+) -> None:
+ """Test turning on the siren."""
+ state = hass.states.get("siren.smoke_sensor_siren")
+ assert state
+ assert state.state == "on"
+
+ set_node_attribute(
+ matter_node,
+ 1,
+ HeimanCluster.id,
+ HeimanCluster.Attributes.SirenActive.attribute_id,
+ 0,
+ )
+ await trigger_subscription_callback(hass, matter_client)
+
+ state = hass.states.get("siren.smoke_sensor_siren")
+ assert state
+ assert state.state == "off"
+
+ await hass.services.async_call(
+ "siren",
+ "turn_on",
+ {"entity_id": "siren.smoke_sensor_siren"},
+ blocking=True,
+ )
+
+ assert matter_client.write_attribute.call_count == 1
+ assert matter_client.write_attribute.call_args == call(
+ node_id=matter_node.node_id,
+ attribute_path=create_attribute_path_from_attribute(
+ endpoint_id=1,
+ attribute=HeimanCluster.Attributes.SirenActive,
+ ),
+ value=1,
+ )
+
+
+@pytest.mark.parametrize("node_fixture", ["heiman_smoke_detector"])
+async def test_turn_off(
+ hass: HomeAssistant,
+ matter_client: MagicMock,
+ matter_node: MatterNode,
+) -> None:
+ """Test turning off the siren."""
+ state = hass.states.get("siren.smoke_sensor_siren")
+ assert state
+ assert state.state == "on"
+
+ await hass.services.async_call(
+ "siren",
+ "turn_off",
+ {"entity_id": "siren.smoke_sensor_siren"},
+ blocking=True,
+ )
+
+ assert matter_client.write_attribute.call_count == 1
+ assert matter_client.write_attribute.call_args == call(
+ node_id=matter_node.node_id,
+ attribute_path=create_attribute_path_from_attribute(
+ endpoint_id=1,
+ attribute=HeimanCluster.Attributes.SirenActive,
+ ),
+ value=0,
+ )
+
+
+@pytest.mark.parametrize("node_fixture", ["heiman_smoke_detector"])
+async def test_unknown_state(
+ hass: HomeAssistant,
+ matter_client: MagicMock,
+ matter_node: MatterNode,
+) -> None:
+ """Test that a None attribute value results in an unknown state."""
+ set_node_attribute(
+ matter_node,
+ 1,
+ HeimanCluster.id,
+ HeimanCluster.Attributes.SirenActive.attribute_id,
+ None,
+ )
+ await trigger_subscription_callback(hass, matter_client)
+
+ state = hass.states.get("siren.smoke_sensor_siren")
+ assert state
+ assert state.state == "unknown"
diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py
index e989457644c5a7..c94b98f3ce0926 100644
--- a/tests/components/prusalink/conftest.py
+++ b/tests/components/prusalink/conftest.py
@@ -49,6 +49,8 @@ def mock_info_api() -> Generator[dict[str, Any]]:
"hostname": "PrusaXL",
"min_extrusion_temp": 170,
"location": "Workshop",
+ "sd_ready": True,
+ "farm_mode": False,
}
with patch("pyprusalink.PrusaLink.get_info", return_value=resp):
yield resp
@@ -84,6 +86,7 @@ def mock_get_status_idle() -> Generator[dict[str, Any]]:
"speed": 100,
"fan_hotend": 100,
"fan_print": 75,
+ "status_connect": {"ok": True, "message": ""},
},
}
with patch("pyprusalink.PrusaLink.get_status", return_value=resp):
@@ -112,6 +115,7 @@ def mock_get_status_printing() -> Generator[dict[str, Any]]:
"speed": 100,
"fan_hotend": 5000,
"fan_print": 2500,
+ "status_connect": {"ok": True, "message": ""},
},
}
with patch("pyprusalink.PrusaLink.get_status", return_value=resp):
diff --git a/tests/components/prusalink/test_binary_sensor.py b/tests/components/prusalink/test_binary_sensor.py
index 474a4e265d1509..581347bdc81d13 100644
--- a/tests/components/prusalink/test_binary_sensor.py
+++ b/tests/components/prusalink/test_binary_sensor.py
@@ -1,13 +1,16 @@
"""Test Prusalink sensors."""
+from typing import Any
from unittest.mock import patch
import pytest
-from homeassistant.const import STATE_OFF, Platform
+from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
+from tests.common import MockConfigEntry
+
@pytest.fixture(autouse=True)
def setup_binary_sensor_platform_only():
@@ -20,7 +23,7 @@ def setup_binary_sensor_platform_only():
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors_no_job(
- hass: HomeAssistant, mock_config_entry, mock_api
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: None
) -> None:
"""Test sensors while no job active."""
assert await async_setup_component(hass, "prusalink", {})
@@ -28,3 +31,65 @@ async def test_binary_sensors_no_job(
state = hass.states.get("binary_sensor.mock_title_mmu")
assert state is not None
assert state.state == STATE_OFF
+
+
+async def test_status_connect_enabled_by_default(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: None
+) -> None:
+ """Connect binary sensor is enabled by default and reflects status_connect.ok."""
+ assert await async_setup_component(hass, "prusalink", {})
+
+ state = hass.states.get("binary_sensor.mock_title_connectivity")
+ assert state is not None
+ assert state.state == STATE_ON
+
+
+async def test_status_connect_not_created_when_absent(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_api: None,
+ mock_get_status_idle: dict[str, Any],
+) -> None:
+ """Connect sensor is not created when status_connect is not in the response."""
+ del mock_get_status_idle["printer"]["status_connect"]
+ assert await async_setup_component(hass, "prusalink", {})
+
+ assert hass.states.get("binary_sensor.mock_title_connectivity") is None
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_sd_ready(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: None
+) -> None:
+ """SD card sensor reflects sd_ready from info endpoint."""
+ assert await async_setup_component(hass, "prusalink", {})
+
+ state = hass.states.get("binary_sensor.mock_title_sd_card")
+ assert state is not None
+ assert state.state == STATE_ON
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_farm_mode(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: None
+) -> None:
+ """Farm mode sensor reflects farm_mode from info endpoint."""
+ assert await async_setup_component(hass, "prusalink", {})
+
+ state = hass.states.get("binary_sensor.mock_title_farm_mode")
+ assert state is not None
+ assert state.state == STATE_OFF
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_farm_mode_not_created_when_absent(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_api: None,
+ mock_info_api: dict[str, Any],
+) -> None:
+ """Farm mode sensor is not created when farm_mode field is absent from info."""
+ del mock_info_api["farm_mode"]
+ assert await async_setup_component(hass, "prusalink", {})
+
+ assert hass.states.get("binary_sensor.mock_title_farm_mode") is None
diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json
index 883eca192762e1..d31c0141700744 100644
--- a/tests/components/qbus/fixtures/payload_config.json
+++ b/tests/components/qbus/fixtures/payload_config.json
@@ -517,6 +517,35 @@
},
"refId": "000001/94/1",
"type": "humidity"
+ },
+ {
+ "id": "UL90",
+ "location": "Living",
+ "locationId": 0,
+ "name": "Stepper",
+ "originalName": "Stepper",
+ "properties": {
+ "value": {
+ "read": true,
+ "type": "number",
+ "valueList": [
+ { "name": "0", "value": 0 },
+ { "name": "Here comes", "value": 1 },
+ { "name": "the hotstepper", "value": 2 },
+ { "name": "3", "value": 3 },
+ { "name": "item 4", "value": 4 },
+ { "name": "5", "value": 5 },
+ { "name": "6", "value": 6 },
+ { "name": "7", "value": 7 },
+ { "name": "8", "value": 8 },
+ { "name": "9", "value": 9 },
+ { "name": "item 10, last", "value": 10 }
+ ],
+ "write": true
+ }
+ },
+ "refId": "000001/68",
+ "type": "stepper"
}
]
}
diff --git a/tests/components/qbus/snapshots/test_select.ambr b/tests/components/qbus/snapshots/test_select.ambr
new file mode 100644
index 00000000000000..4fa87c139f9fc1
--- /dev/null
+++ b/tests/components/qbus/snapshots/test_select.ambr
@@ -0,0 +1,78 @@
+# serializer version: 1
+# name: test_select[select.ctd_000001_stepper-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': list([
+ None,
+ ]),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ '0',
+ 'Here comes',
+ 'the hotstepper',
+ '3',
+ 'item 4',
+ '5',
+ '6',
+ '7',
+ '8',
+ '9',
+ 'item 10, last',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'select',
+ 'entity_category': None,
+ 'entity_id': 'select.ctd_000001_stepper',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Stepper',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Stepper',
+ 'platform': 'qbus',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'ctd_000001_68',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_select[select.ctd_000001_stepper-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'CTD 000001 Stepper',
+ 'options': list([
+ '0',
+ 'Here comes',
+ 'the hotstepper',
+ '3',
+ 'item 4',
+ '5',
+ '6',
+ '7',
+ '8',
+ '9',
+ 'item 10, last',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'select.ctd_000001_stepper',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
diff --git a/tests/components/qbus/test_select.py b/tests/components/qbus/test_select.py
new file mode 100644
index 00000000000000..cfe68de962db91
--- /dev/null
+++ b/tests/components/qbus/test_select.py
@@ -0,0 +1,106 @@
+"""Test Qbus selects."""
+
+from collections.abc import Awaitable, Callable
+from unittest.mock import patch
+
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.select import (
+ DOMAIN as SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry, async_fire_mqtt_message, snapshot_platform
+from tests.typing import MqttMockHAClient
+
+_PAYLOAD_STEPPER_STATE_THE_HOTSTEPPER = (
+ '{"id":"UL90","properties":{"value":2},"type":"state"}'
+)
+_PAYLOAD_STEPPER_SET_THE_HOTSTEPPER = (
+ '{"id": "UL90", "type": "state", "properties": {"value": 2}}'
+)
+
+_TOPIC_STEPPER_STATE = "cloudapp/QBUSMQTTGW/UL1/UL90/state"
+_TOPIC_STEPPER_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL90/setState"
+
+_STEPPER_ENTITY_ID = "select.ctd_000001_stepper"
+
+
+async def test_select(
+ hass: HomeAssistant,
+ setup_integration_deferred: Callable[[], Awaitable],
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test select."""
+
+ with patch("homeassistant.components.qbus.PLATFORMS", [Platform.SELECT]):
+ await setup_integration_deferred()
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_select_option_updates_stepper(
+ hass: HomeAssistant,
+ mqtt_mock: MqttMockHAClient,
+ setup_integration: None,
+) -> None:
+ """Test selecting an option."""
+
+ state = hass.states.get(_STEPPER_ENTITY_ID)
+ assert state is not None
+
+ options = state.attributes["options"]
+ assert options
+
+ mqtt_mock.reset_mock()
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: _STEPPER_ENTITY_ID,
+ ATTR_OPTION: "the hotstepper",
+ },
+ blocking=True,
+ )
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ _TOPIC_STEPPER_SET_STATE,
+ _PAYLOAD_STEPPER_SET_THE_HOTSTEPPER,
+ 0,
+ False,
+ message_expiry_interval=None,
+ )
+
+ async_fire_mqtt_message(
+ hass, _TOPIC_STEPPER_STATE, _PAYLOAD_STEPPER_STATE_THE_HOTSTEPPER
+ )
+ await hass.async_block_till_done()
+
+ entity = hass.states.get(_STEPPER_ENTITY_ID)
+ assert entity.state == "the hotstepper"
+
+
+async def test_select_with_unknown_option(
+ hass: HomeAssistant,
+ mqtt_mock: MqttMockHAClient,
+ setup_integration: None,
+) -> None:
+ """Test select with passing an unknown option value."""
+
+ with pytest.raises(ServiceValidationError):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: _STEPPER_ENTITY_ID,
+ ATTR_OPTION: "I'm the lyrical gangster",
+ },
+ blocking=True,
+ )
diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py
index 41a8f32eadbb1c..60d5a7e26842d6 100644
--- a/tests/components/switchbot/__init__.py
+++ b/tests/components/switchbot/__init__.py
@@ -1081,6 +1081,34 @@ def make_advertisement(
tx_power=-127,
)
+PERMANENT_OUTDOOR_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak(
+ name="Permanent Outdoor Light",
+ manufacturer_data={
+ 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00',
+ },
+ service_data={
+ "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb7"
+ },
+ service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
+ address="AA:BB:CC:DD:EE:FF",
+ rssi=-60,
+ source="local",
+ advertisement=generate_advertisement_data(
+ local_name="Permanent Outdoor Light",
+ manufacturer_data={
+ 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00',
+ },
+ service_data={
+ "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb7"
+ },
+ service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
+ ),
+ device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Permanent Outdoor Light"),
+ time=0,
+ connectable=True,
+ tx_power=-127,
+)
+
PLUG_MINI_EU_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Plug Mini (EU)",
manufacturer_data={
diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py
index c499f5ff36a21f..5d1a39d1743973 100644
--- a/tests/components/switchbot/test_light.py
+++ b/tests/components/switchbot/test_light.py
@@ -29,6 +29,7 @@
BULB_SERVICE_INFO,
CEILING_LIGHT_SERVICE_INFO,
FLOOR_LAMP_SERVICE_INFO,
+ PERMANENT_OUTDOOR_LIGHT_SERVICE_INFO,
RGBICWW_FLOOR_LAMP_SERVICE_INFO,
RGBICWW_STRIP_LIGHT_SERVICE_INFO,
STRIP_LIGHT_3_SERVICE_INFO,
@@ -379,6 +380,11 @@ async def test_strip_light_services_exception(
"SwitchbotRgbicLight",
),
("rgbicww_floor_lamp", RGBICWW_FLOOR_LAMP_SERVICE_INFO, "SwitchbotRgbicLight"),
+ (
+ "permanent_outdoor_light",
+ PERMANENT_OUTDOOR_LIGHT_SERVICE_INFO,
+ "SwitchbotPermanentOutdoorLight",
+ ),
],
)
@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS)
diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr
index 1c8f678a4e87f6..97d26a532b53cb 100644
--- a/tests/components/tractive/snapshots/test_binary_sensor.ambr
+++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_binary_sensor[binary_sensor.test_pet_tracker_battery_charging-entry]
+# name: test_binary_sensor[binary_sensor.tracker_device_id_123_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -13,7 +13,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': ,
- 'entity_id': 'binary_sensor.test_pet_tracker_battery_charging',
+ 'entity_id': 'binary_sensor.tracker_device_id_123_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -21,36 +21,36 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker battery charging',
+ 'object_id_base': 'Charging',
'options': dict({
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'Tracker battery charging',
+ 'original_name': 'Charging',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'tracker_battery_charging',
+ 'translation_key': None,
'unique_id': 'pet_id_123_battery_charging',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensor[binary_sensor.test_pet_tracker_battery_charging-state]
+# name: test_binary_sensor[binary_sensor.tracker_device_id_123_charging-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery_charging',
- 'friendly_name': 'Test Pet Tracker battery charging',
+ 'friendly_name': 'Tracker device_id_123 Charging',
}),
'context': ,
- 'entity_id': 'binary_sensor.test_pet_tracker_battery_charging',
+ 'entity_id': 'binary_sensor.tracker_device_id_123_charging',
'last_changed': ,
'last_reported': ,
'last_updated': ,
'state': 'on',
})
# ---
-# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-entry]
+# name: test_binary_sensor[binary_sensor.tracker_device_id_123_power_saving-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -64,7 +64,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': ,
- 'entity_id': 'binary_sensor.test_pet_tracker_power_saving',
+ 'entity_id': 'binary_sensor.tracker_device_id_123_power_saving',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -72,12 +72,12 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker power saving',
+ 'object_id_base': 'Power saving',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
- 'original_name': 'Tracker power saving',
+ 'original_name': 'Power saving',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -87,13 +87,13 @@
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-state]
+# name: test_binary_sensor[binary_sensor.tracker_device_id_123_power_saving-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'friendly_name': 'Test Pet Tracker power saving',
+ 'friendly_name': 'Tracker device_id_123 Power saving',
}),
'context': ,
- 'entity_id': 'binary_sensor.test_pet_tracker_power_saving',
+ 'entity_id': 'binary_sensor.tracker_device_id_123_power_saving',
'last_changed': ,
'last_reported': ,
'last_updated': ,
diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr
index 96f616182a296f..f563dc640aa5ec 100644
--- a/tests/components/tractive/snapshots/test_device_tracker.ambr
+++ b/tests/components/tractive/snapshots/test_device_tracker.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_device_tracker[device_tracker.test_pet_tracker-entry]
+# name: test_device_tracker[device_tracker.tracker_device_id_123-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -13,7 +13,7 @@
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': ,
- 'entity_id': 'device_tracker.test_pet_tracker',
+ 'entity_id': 'device_tracker.tracker_device_id_123',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -21,12 +21,12 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker',
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
- 'original_name': 'Tracker',
+ 'original_name': None,
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -36,11 +36,11 @@
'unit_of_measurement': None,
})
# ---
-# name: test_device_tracker[device_tracker.test_pet_tracker-state]
+# name: test_device_tracker[device_tracker.tracker_device_id_123-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'battery_level': 88,
- 'friendly_name': 'Test Pet Tracker',
+ 'friendly_name': 'Tracker device_id_123',
'gps_accuracy': 99,
'in_zones': list([
]),
@@ -49,7 +49,7 @@
'source_type': ,
}),
'context': ,
- 'entity_id': 'device_tracker.test_pet_tracker',
+ 'entity_id': 'device_tracker.tracker_device_id_123',
'last_changed': ,
'last_reported': ,
'last_updated': ,
diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr
index 40e20f66e6ffb8..6234c571f7430f 100644
--- a/tests/components/tractive/snapshots/test_sensor.ambr
+++ b/tests/components/tractive/snapshots/test_sensor.ambr
@@ -266,7 +266,7 @@
'state': '122',
})
# ---
-# name: test_sensor[sensor.test_pet_tracker_battery-entry]
+# name: test_sensor[sensor.tracker_device_id_123_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -280,7 +280,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': ,
- 'entity_id': 'sensor.test_pet_tracker_battery',
+ 'entity_id': 'sensor.tracker_device_id_123_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -288,37 +288,37 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker battery',
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'Tracker battery',
+ 'original_name': 'Battery',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'tracker_battery_level',
+ 'translation_key': None,
'unique_id': 'pet_id_123_battery_level',
'unit_of_measurement': '%',
})
# ---
-# name: test_sensor[sensor.test_pet_tracker_battery-state]
+# name: test_sensor[sensor.tracker_device_id_123_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
- 'friendly_name': 'Test Pet Tracker battery',
+ 'friendly_name': 'Tracker device_id_123 Battery',
'unit_of_measurement': '%',
}),
'context': ,
- 'entity_id': 'sensor.test_pet_tracker_battery',
+ 'entity_id': 'sensor.tracker_device_id_123_battery',
'last_changed': ,
'last_reported': ,
'last_updated': ,
'state': '88',
})
# ---
-# name: test_sensor[sensor.test_pet_tracker_state-entry]
+# name: test_sensor[sensor.tracker_device_id_123_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -340,7 +340,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': ,
- 'entity_id': 'sensor.test_pet_tracker_state',
+ 'entity_id': 'sensor.tracker_device_id_123_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -348,12 +348,12 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker state',
+ 'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'Tracker state',
+ 'original_name': 'Status',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -363,11 +363,11 @@
'unit_of_measurement': None,
})
# ---
-# name: test_sensor[sensor.test_pet_tracker_state-state]
+# name: test_sensor[sensor.tracker_device_id_123_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
- 'friendly_name': 'Test Pet Tracker state',
+ 'friendly_name': 'Tracker device_id_123 Status',
'options': list([
'inaccurate_position',
'not_reporting',
@@ -377,7 +377,7 @@
]),
}),
'context': ,
- 'entity_id': 'sensor.test_pet_tracker_state',
+ 'entity_id': 'sensor.tracker_device_id_123_status',
'last_changed': ,
'last_reported': ,
'last_updated': ,
diff --git a/tests/components/tractive/snapshots/test_switch.ambr b/tests/components/tractive/snapshots/test_switch.ambr
index a4ca5515e456cf..4174bfec1a9280 100644
--- a/tests/components/tractive/snapshots/test_switch.ambr
+++ b/tests/components/tractive/snapshots/test_switch.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_switch[switch.test_pet_live_tracking-entry]
+# name: test_switch[switch.tracker_device_id_123_buzzer-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -13,7 +13,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': ,
- 'entity_id': 'switch.test_pet_live_tracking',
+ 'entity_id': 'switch.tracker_device_id_123_buzzer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -21,35 +21,35 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Live tracking',
+ 'object_id_base': 'Buzzer',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
- 'original_name': 'Live tracking',
+ 'original_name': 'Buzzer',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'live_tracking',
- 'unique_id': 'pet_id_123_live_tracking',
+ 'translation_key': 'tracker_buzzer',
+ 'unique_id': 'pet_id_123_buzzer',
'unit_of_measurement': None,
})
# ---
-# name: test_switch[switch.test_pet_live_tracking-state]
+# name: test_switch[switch.tracker_device_id_123_buzzer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'friendly_name': 'Test Pet Live tracking',
+ 'friendly_name': 'Tracker device_id_123 Buzzer',
}),
'context': ,
- 'entity_id': 'switch.test_pet_live_tracking',
+ 'entity_id': 'switch.tracker_device_id_123_buzzer',
'last_changed': ,
'last_reported': ,
'last_updated': ,
'state': 'on',
})
# ---
-# name: test_switch[switch.test_pet_tracker_buzzer-entry]
+# name: test_switch[switch.tracker_device_id_123_led-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -63,7 +63,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': ,
- 'entity_id': 'switch.test_pet_tracker_buzzer',
+ 'entity_id': 'switch.tracker_device_id_123_led',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -71,35 +71,35 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker buzzer',
+ 'object_id_base': 'LED',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
- 'original_name': 'Tracker buzzer',
+ 'original_name': 'LED',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'tracker_buzzer',
- 'unique_id': 'pet_id_123_buzzer',
+ 'translation_key': 'tracker_led',
+ 'unique_id': 'pet_id_123_led',
'unit_of_measurement': None,
})
# ---
-# name: test_switch[switch.test_pet_tracker_buzzer-state]
+# name: test_switch[switch.tracker_device_id_123_led-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'friendly_name': 'Test Pet Tracker buzzer',
+ 'friendly_name': 'Tracker device_id_123 LED',
}),
'context': ,
- 'entity_id': 'switch.test_pet_tracker_buzzer',
+ 'entity_id': 'switch.tracker_device_id_123_led',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'on',
+ 'state': 'off',
})
# ---
-# name: test_switch[switch.test_pet_tracker_led-entry]
+# name: test_switch[switch.tracker_device_id_123_live_tracking-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -113,7 +113,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': ,
- 'entity_id': 'switch.test_pet_tracker_led',
+ 'entity_id': 'switch.tracker_device_id_123_live_tracking',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -121,31 +121,31 @@
'labels': set({
}),
'name': None,
- 'object_id_base': 'Tracker LED',
+ 'object_id_base': 'Live tracking',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
- 'original_name': 'Tracker LED',
+ 'original_name': 'Live tracking',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'tracker_led',
- 'unique_id': 'pet_id_123_led',
+ 'translation_key': 'live_tracking',
+ 'unique_id': 'pet_id_123_live_tracking',
'unit_of_measurement': None,
})
# ---
-# name: test_switch[switch.test_pet_tracker_led-state]
+# name: test_switch[switch.tracker_device_id_123_live_tracking-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'friendly_name': 'Test Pet Tracker LED',
+ 'friendly_name': 'Tracker device_id_123 Live tracking',
}),
'context': ,
- 'entity_id': 'switch.test_pet_tracker_led',
+ 'entity_id': 'switch.tracker_device_id_123_live_tracking',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'off',
+ 'state': 'on',
})
# ---
diff --git a/tests/components/tractive/test_binary_sensor.py b/tests/components/tractive/test_binary_sensor.py
index 283543d761dbf6..7557d0947c50da 100644
--- a/tests/components/tractive/test_binary_sensor.py
+++ b/tests/components/tractive/test_binary_sensor.py
@@ -4,9 +4,10 @@
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.tractive.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration
@@ -27,3 +28,28 @@ async def test_binary_sensor(
mock_tractive_client.send_hardware_event(mock_config_entry)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_binary_sensor_device_assignment(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ mock_tractive_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that binary sensor entities are assigned to the tracker device."""
+ with patch("homeassistant.components.tractive.PLATFORMS", [Platform.BINARY_SENSOR]):
+ await init_integration(hass, mock_config_entry)
+
+ tracker_device = device_registry.async_get_device(
+ identifiers={(DOMAIN, "device_id_123")}
+ )
+ assert tracker_device is not None
+
+ for entity_id in (
+ "binary_sensor.tracker_device_id_123_charging",
+ "binary_sensor.tracker_device_id_123_power_saving",
+ ):
+ entry = entity_registry.async_get(entity_id)
+ assert entry is not None
+ assert entry.device_id == tracker_device.id
diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py
index ee30ca4a1f5b08..a813d1bcf91fb6 100644
--- a/tests/components/tractive/test_device_tracker.py
+++ b/tests/components/tractive/test_device_tracker.py
@@ -5,9 +5,10 @@
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.device_tracker import SourceType
+from homeassistant.components.tractive.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration
@@ -56,7 +57,9 @@ async def test_source_type_phone(
await hass.async_block_till_done()
assert (
- hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"]
+ hass.states.get("device_tracker.tracker_device_id_123").attributes[
+ "source_type"
+ ]
is SourceType.BLUETOOTH
)
@@ -84,7 +87,9 @@ async def test_source_type_gps(
await hass.async_block_till_done()
assert (
- hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"]
+ hass.states.get("device_tracker.tracker_device_id_123").attributes[
+ "source_type"
+ ]
is SourceType.GPS
)
@@ -105,6 +110,29 @@ async def test_device_tracker_with_empty_hw_info(
mock_tractive_client.send_position_event(mock_config_entry)
await hass.async_block_till_done()
- state = hass.states.get("device_tracker.test_pet_tracker")
+ state = hass.states.get("device_tracker.tracker_device_id_123")
assert state is not None
assert state.attributes.get("battery_level") is None
+
+
+async def test_device_tracker_device_assignment(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ mock_tractive_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that the device tracker entity is assigned to the tracker device."""
+ with patch(
+ "homeassistant.components.tractive.PLATFORMS", [Platform.DEVICE_TRACKER]
+ ):
+ await init_integration(hass, mock_config_entry)
+
+ tracker_device = device_registry.async_get_device(
+ identifiers={(DOMAIN, "device_id_123")}
+ )
+ assert tracker_device is not None
+
+ entry = entity_registry.async_get("device_tracker.tracker_device_id_123")
+ assert entry is not None
+ assert entry.device_id == tracker_device.id
diff --git a/tests/components/tractive/test_init.py b/tests/components/tractive/test_init.py
index beba2c2bc8ae76..5e68a72a0f68b9 100644
--- a/tests/components/tractive/test_init.py
+++ b/tests/components/tractive/test_init.py
@@ -150,7 +150,7 @@ async def test_server_unavailable(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test states of the sensor."""
- entity_id = "sensor.test_pet_tracker_battery"
+ entity_id = "sensor.tracker_device_id_123_battery"
await init_integration(hass, mock_config_entry)
diff --git a/tests/components/tractive/test_sensor.py b/tests/components/tractive/test_sensor.py
index 92dac224a0c743..f892a2c46be358 100644
--- a/tests/components/tractive/test_sensor.py
+++ b/tests/components/tractive/test_sensor.py
@@ -4,9 +4,10 @@
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.tractive.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration
@@ -28,3 +29,42 @@ async def test_sensor(
mock_tractive_client.send_health_overview_event(mock_config_entry)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_sensor_device_assignment(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ mock_tractive_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that hardware sensors are on the tracker device and health sensors on the pet device."""
+ with patch("homeassistant.components.tractive.PLATFORMS", [Platform.SENSOR]):
+ await init_integration(hass, mock_config_entry)
+
+ tracker_device = device_registry.async_get_device(
+ identifiers={(DOMAIN, "device_id_123")}
+ )
+ assert tracker_device is not None
+
+ pet_device = device_registry.async_get_device(identifiers={(DOMAIN, "pet_id_123")})
+ assert pet_device is not None
+
+ for entity_id in (
+ "sensor.tracker_device_id_123_battery",
+ "sensor.tracker_device_id_123_status",
+ ):
+ entry = entity_registry.async_get(entity_id)
+ assert entry is not None
+ assert entry.device_id == tracker_device.id
+
+ for entity_id in (
+ "sensor.test_pet_activity_time",
+ "sensor.test_pet_rest_time",
+ "sensor.test_pet_daily_goal",
+ "sensor.test_pet_day_sleep",
+ "sensor.test_pet_night_sleep",
+ ):
+ entry = entity_registry.async_get(entity_id)
+ assert entry is not None
+ assert entry.device_id == pet_device.id
diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py
index 60d1678af31c0a..77bd8f26e05f0f 100644
--- a/tests/components/tractive/test_switch.py
+++ b/tests/components/tractive/test_switch.py
@@ -7,6 +7,7 @@
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.components.tractive.const import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
@@ -16,8 +17,9 @@
STATE_UNAVAILABLE,
Platform,
)
-from homeassistant.core import HomeAssistant, HomeAssistantError
-from homeassistant.helpers import entity_registry as er
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration
@@ -46,7 +48,7 @@ async def test_switch_on(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the switch can be turned on."""
- entity_id = "switch.test_pet_tracker_led"
+ entity_id = "switch.tracker_device_id_123_led"
await init_integration(hass, mock_config_entry)
@@ -85,7 +87,7 @@ async def test_switch_off(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the switch can be turned off."""
- entity_id = "switch.test_pet_tracker_buzzer"
+ entity_id = "switch.tracker_device_id_123_buzzer"
await init_integration(hass, mock_config_entry)
@@ -125,7 +127,7 @@ async def test_live_tracking_switch(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the live_tracking switch."""
- entity_id = "switch.test_pet_live_tracking"
+ entity_id = "switch.tracker_device_id_123_live_tracking"
await init_integration(hass, mock_config_entry)
@@ -170,7 +172,7 @@ async def test_switch_on_with_exception(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the switch turn on with exception."""
- entity_id = "switch.test_pet_tracker_led"
+ entity_id = "switch.tracker_device_id_123_led"
await init_integration(hass, mock_config_entry)
@@ -206,7 +208,7 @@ async def test_switch_off_with_exception(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the switch turn off with exception."""
- entity_id = "switch.test_pet_tracker_buzzer"
+ entity_id = "switch.tracker_device_id_123_buzzer"
await init_integration(hass, mock_config_entry)
@@ -244,7 +246,7 @@ async def test_switch_unavailable(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the switch is navailable when the tracker is in the energy saving zone."""
- entity_id = "switch.test_pet_tracker_buzzer"
+ entity_id = "switch.tracker_device_id_123_buzzer"
await init_integration(hass, mock_config_entry)
@@ -274,3 +276,29 @@ async def test_switch_unavailable(
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
+
+
+async def test_switch_device_assignment(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
+ mock_tractive_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that switch entities are assigned to the tracker device."""
+ with patch("homeassistant.components.tractive.PLATFORMS", [Platform.SWITCH]):
+ await init_integration(hass, mock_config_entry)
+
+ tracker_device = device_registry.async_get_device(
+ identifiers={(DOMAIN, "device_id_123")}
+ )
+ assert tracker_device is not None
+
+ for entity_id in (
+ "switch.tracker_device_id_123_buzzer",
+ "switch.tracker_device_id_123_led",
+ "switch.tracker_device_id_123_live_tracking",
+ ):
+ entry = entity_registry.async_get(entity_id)
+ assert entry is not None
+ assert entry.device_id == tracker_device.id
diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr
index d9f4d3fd3f2b23..ddcba917c2f681 100644
--- a/tests/components/tuya/snapshots/test_number.ambr
+++ b/tests/components/tuya/snapshots/test_number.ambr
@@ -38,7 +38,7 @@
'supported_features': 0,
'translation_key': 'alarm_duration',
'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_time',
- 'unit_of_measurement': ,
+ 'unit_of_measurement': 's',
})
# ---
# name: test_platform_setup_and_discovery[number.aqi_alarm_duration-state]
@@ -50,7 +50,7 @@
'min': 1.0,
'mode': ,
'step': 1.0,
- 'unit_of_measurement': ,
+ 'unit_of_measurement': 's',
}),
'context': ,
'entity_id': 'number.aqi_alarm_duration',
@@ -3123,7 +3123,7 @@
'supported_features': 0,
'translation_key': 'cook_time',
'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_time',
- 'unit_of_measurement': ,
+ 'unit_of_measurement': 'min',
})
# ---
# name: test_platform_setup_and_discovery[number.sous_vide_cooking_time-state]
@@ -3134,7 +3134,7 @@
'min': 1.0,
'mode': ,
'step': 1.0,
- 'unit_of_measurement': ,
+ 'unit_of_measurement': 'min',
}),
'context': ,
'entity_id': 'number.sous_vide_cooking_time',
diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr
index b0617a70ae27c4..a1901c8f1f57af 100644
--- a/tests/components/tuya/snapshots/test_sensor.ambr
+++ b/tests/components/tuya/snapshots/test_sensor.ambr
@@ -21568,7 +21568,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '19.06',
+ 'state': '19060.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.soria_temperature-entry]
@@ -21779,14 +21779,14 @@
'supported_features': 0,
'translation_key': 'remaining_time',
'unique_id': 'tuya.hyda5jsihokacvaqjzmremain_time',
- 'unit_of_measurement': ,
+ 'unit_of_measurement': 'min',
})
# ---
# name: test_platform_setup_and_discovery[sensor.sous_vide_remaining_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Sous Vide Remaining time',
- 'unit_of_measurement': ,
+ 'unit_of_measurement': 'min',
}),
'context': ,
'entity_id': 'sensor.sous_vide_remaining_time',
diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py
index f444d08bee1152..12d8458d2f0cfe 100644
--- a/tests/components/tuya/test_number.py
+++ b/tests/components/tuya/test_number.py
@@ -15,7 +15,8 @@
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import entity_registry as er, json
+from homeassistant.util import json as json_util
from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry
@@ -119,3 +120,60 @@ async def test_set_value(
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "delay_set", "value": 18}]
)
+
+
+@pytest.mark.parametrize(
+ (
+ "mock_device_code",
+ "entity_id",
+ "dpcode",
+ "tuya_uom",
+ "expected_msg",
+ ),
+ [
+ (
+ "co2bj_yrr3eiyiacm31ski",
+ "number.aqi_alarm_duration",
+ "alarm_time",
+ "invalid_uom",
+ (
+ "Incompatible unit invalid_uom replaced by entity description "
+ "unit s for device class duration in number entity "
+ "tuya.iks13mcaiyie3rryjb2ocalarm_time; use a quirk "
+ "(https://github.com/home-assistant-libs/tuya-device-handlers) "
+ "to override"
+ ),
+ ),
+ (
+ "znrb_gpzittzfnzhduquz",
+ "number.inverter_pool_heat_pump_temperature",
+ "temp_set",
+ "",
+ (
+ "Device class temperature ignored for incompatible unit in "
+ "number entity tuya.zuqudhznfzttizpgbrnztemp_set"
+ ),
+ ),
+ ],
+)
+async def test_invalid_uom(
+ hass: HomeAssistant,
+ mock_manager: Manager,
+ mock_config_entry: MockConfigEntry,
+ mock_device: CustomerDevice,
+ entity_id: str,
+ dpcode: str,
+ tuya_uom: str,
+ expected_msg: str,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test invalid unit of measurement."""
+ values = json_util.json_loads_object(mock_device.status_range[dpcode].values)
+ values["unit"] = tuya_uom
+ mock_device.function[dpcode].values = json.json_dumps(values)
+ mock_device.status_range[dpcode].values = json.json_dumps(values)
+ await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
+
+ state = hass.states.get(entity_id)
+ assert state is not None, f"{entity_id} does not exist"
+ assert expected_msg in caplog.text
diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py
index bb9606e6a23a1a..0c7b9435276bbb 100644
--- a/tests/components/tuya/test_sensor.py
+++ b/tests/components/tuya/test_sensor.py
@@ -11,7 +11,8 @@
from homeassistant.components.sensor import SensorStateClass
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import entity_registry as er, json
+from homeassistant.util import json as json_util
from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry
@@ -181,3 +182,59 @@ async def test_delta_report_sensor(
state = hass.states.get(entity_id)
assert state is not None
assert float(state.state) == pytest.approx(0.6) # unchanged
+
+
+@pytest.mark.parametrize(
+ (
+ "mock_device_code",
+ "entity_id",
+ "dpcode",
+ "tuya_uom",
+ "expected_msg",
+ ),
+ [
+ (
+ "dlq_0tnvg2xaisqdadcf",
+ "sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy",
+ "add_ele",
+ "invalid_uom",
+ (
+ "Incompatible unit invalid_uom replaced by entity description "
+ "unit kWh for device class energy in sensor entity "
+ "tuya.fcdadqsiax2gvnt0qldadd_ele; use a quirk "
+ "(https://github.com/home-assistant-libs/tuya-device-handlers) "
+ "to override"
+ ),
+ ),
+ (
+ "znrb_gpzittzfnzhduquz",
+ "sensor.inverter_pool_heat_pump_temperature",
+ "temp_current",
+ "",
+ (
+ "Device class temperature ignored for incompatible unit in "
+ "sensor entity tuya.zuqudhznfzttizpgbrnztemp_current"
+ ),
+ ),
+ ],
+)
+async def test_invalid_uom(
+ hass: HomeAssistant,
+ mock_manager: Manager,
+ mock_config_entry: MockConfigEntry,
+ mock_device: CustomerDevice,
+ entity_id: str,
+ dpcode: str,
+ tuya_uom: str,
+ expected_msg: str,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test invalid unit of measurement."""
+ values = json_util.json_loads_object(mock_device.status_range[dpcode].values)
+ values["unit"] = tuya_uom
+ mock_device.status_range[dpcode].values = json.json_dumps(values)
+ await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
+
+ state = hass.states.get(entity_id)
+ assert state is not None, f"{entity_id} does not exist"
+ assert expected_msg in caplog.text
diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py
index 6ca264c89625c7..9590c29f79b9b7 100644
--- a/tests/components/voip/conftest.py
+++ b/tests/components/voip/conftest.py
@@ -39,15 +39,18 @@ async def setup_voip(hass: HomeAssistant, config_entry: MockConfigEntry) -> None
"homeassistant.components.voip._create_sip_server",
return_value=(Mock(), AsyncMock()),
):
- assert await async_setup_component(hass, DOMAIN, {})
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
yield
@pytest.fixture
-async def voip_devices(hass: HomeAssistant, setup_voip: None) -> VoIPDevices:
+async def voip_devices(
+ hass: HomeAssistant, config_entry: MockConfigEntry, setup_voip: None
+) -> VoIPDevices:
"""Get VoIP devices object from a configured instance."""
- return hass.data[DOMAIN].devices
+ return config_entry.runtime_data.domain_data.devices
@pytest.fixture
diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py
index 06759745a7180c..2771ab7d66c313 100644
--- a/tests/components/voip/test_devices.py
+++ b/tests/components/voip/test_devices.py
@@ -103,6 +103,7 @@ async def test_device_load_contact(
call_info: CallInfo,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
+ setup_voip: None,
) -> None:
"""Test loading contact endpoint from Store."""
voip_id = call_info.caller_endpoint.uri
@@ -111,7 +112,7 @@ async def test_device_load_contact(
return_value={voip_id: {"contact": "Test "}}
)
- config_entry.runtime_data = mock_store
+ config_entry.runtime_data.store = mock_store
# Initialize voip device
device_registry.async_get_or_create(
@@ -124,7 +125,7 @@ async def test_device_load_contact(
configuration_url=f"http://{call_info.caller_ip}",
)
- voip = VoIPDevices(hass, config_entry)
+ voip = VoIPDevices(hass, config_entry, mock_store)
await voip.async_setup()
voip_device = voip.devices.get(voip_id)
diff --git a/tests/components/voip/test_switch.py b/tests/components/voip/test_switch.py
index 8b3cd03f2acae3..66b813a956234d 100644
--- a/tests/components/voip/test_switch.py
+++ b/tests/components/voip/test_switch.py
@@ -1,13 +1,15 @@
"""Test VoIP switch devices."""
from homeassistant.components.voip.devices import VoIPDevice
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from tests.common import MockConfigEntry
+
async def test_allow_call(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ setup_voip: None,
+ config_entry: MockConfigEntry,
voip_device: VoIPDevice,
) -> None:
"""Test allow call."""
diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py
index 991132f970dba2..07b8b5769011d3 100644
--- a/tests/components/voip/test_voip.py
+++ b/tests/components/voip/test_voip.py
@@ -16,7 +16,7 @@
# pylint: disable-next=home-assistant-component-root-import
from homeassistant.components.assist_satellite.entity import AssistSatelliteState
-from homeassistant.components.voip import DOMAIN, HassVoipDatagramProtocol
+from homeassistant.components.voip import HassVoipDatagramProtocol
from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite
from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices
from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol
@@ -27,6 +27,7 @@
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.setup import async_setup_component
+from tests.common import MockConfigEntry
from tests.components.tts.common import MockResultStream
_ONE_SECOND = 16000 * 2 # 16Khz 16-bit
@@ -828,6 +829,7 @@ async def async_send_audio(audio_bytes: bytes, **kwargs):
@pytest.mark.usefixtures("socket_enabled")
async def test_announce(
hass: HomeAssistant,
+ config_entry: MockConfigEntry,
voip_devices: VoIPDevices,
voip_device: VoIPDevice,
) -> None:
@@ -864,7 +866,7 @@ async def test_announce(
)
# Protocol has already been mocked, but "outgoing_call" is not async
- mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
+ mock_protocol: AsyncMock = config_entry.runtime_data.domain_data.protocol
mock_protocol.outgoing_call = Mock()
with (
@@ -896,6 +898,7 @@ async def test_announce(
@pytest.mark.usefixtures("socket_enabled")
async def test_voip_id_is_ip_address(
hass: HomeAssistant,
+ config_entry: MockConfigEntry,
voip_devices: VoIPDevices,
voip_device: VoIPDevice,
) -> None:
@@ -919,7 +922,7 @@ async def test_voip_id_is_ip_address(
)
# Protocol has already been mocked, but "outgoing_call" is not async
- mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
+ mock_protocol: AsyncMock = config_entry.runtime_data.domain_data.protocol
mock_protocol.outgoing_call = Mock()
with (
@@ -956,6 +959,7 @@ async def test_voip_id_is_ip_address(
@pytest.mark.usefixtures("socket_enabled")
async def test_announce_timeout(
hass: HomeAssistant,
+ config_entry: MockConfigEntry,
voip_devices: VoIPDevices,
voip_device: VoIPDevice,
) -> None:
@@ -979,7 +983,7 @@ async def test_announce_timeout(
)
# Protocol has already been mocked, but some methods are not async
- mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
+ mock_protocol: AsyncMock = config_entry.runtime_data.domain_data.protocol
mock_protocol.outgoing_call = Mock()
mock_protocol.cancel_call = Mock()
@@ -998,6 +1002,7 @@ async def test_announce_timeout(
@pytest.mark.usefixtures("socket_enabled")
async def test_start_conversation(
hass: HomeAssistant,
+ config_entry: MockConfigEntry,
voip_devices: VoIPDevices,
voip_device: VoIPDevice,
) -> None:
@@ -1021,7 +1026,7 @@ async def test_start_conversation(
)
# Protocol has already been mocked, but "outgoing_call" is not async
- mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
+ mock_protocol: AsyncMock = config_entry.runtime_data.domain_data.protocol
mock_protocol.outgoing_call = Mock()
tts_sent = asyncio.Event()
@@ -1106,6 +1111,7 @@ async def async_pipeline_from_audio_stream(
@pytest.mark.usefixtures("socket_enabled")
async def test_start_conversation_user_doesnt_pick_up(
hass: HomeAssistant,
+ config_entry: MockConfigEntry,
voip_devices: VoIPDevices,
voip_device: VoIPDevice,
) -> None:
@@ -1122,7 +1128,7 @@ async def test_start_conversation_user_doesnt_pick_up(
# Protocol has already been mocked, but "outgoing_call" and
# "cancel_call" are not async
- mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
+ mock_protocol: AsyncMock = config_entry.runtime_data.domain_data.protocol
mock_protocol.outgoing_call = Mock()
mock_protocol.cancel_call = Mock()
diff --git a/tests/components/wled/snapshots/test_light.ambr b/tests/components/wled/snapshots/test_light.ambr
index 1d49979e407d08..33f13c706977ef 100644
--- a/tests/components/wled/snapshots/test_light.ambr
+++ b/tests/components/wled/snapshots/test_light.ambr
@@ -918,6 +918,10 @@
]),
'area_id': None,
'capabilities': dict({
+ 'group_entities': list([
+ 'light.wled_rgb_light',
+ 'light.wled_rgb_light_segment_1',
+ ]),
'supported_color_modes': list([
,
]),
@@ -958,6 +962,10 @@
'brightness': 128,
'color_mode': ,
'friendly_name': 'WLED RGB Light Main',
+ 'group_entities': list([
+ 'light.wled_rgb_light',
+ 'light.wled_rgb_light_segment_1',
+ ]),
'supported_color_modes': list([
,
]),
diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py
index 412720ae89ea8e..5f64a8433eaa32 100644
--- a/tests/components/wled/test_light.py
+++ b/tests/components/wled/test_light.py
@@ -29,6 +29,7 @@
)
from homeassistant.const import (
ATTR_ENTITY_ID,
+ ATTR_GROUP_ENTITIES,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
@@ -388,3 +389,52 @@ async def test_cct_light(hass: HomeAssistant, mock_wled: MagicMock) -> None:
on=True,
segment_id=0,
)
+
+
+@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"])
+async def test_main_light_group_updates_when_segments_change(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_wled: MagicMock,
+ init_integration: MockConfigEntry,
+) -> None:
+ """Test that the main light group field updates when segments are dynamically added or removed."""
+ single_segment_data = mock_wled.update.return_value
+ two_segment_data = WLEDDevice.from_dict(
+ await async_load_json_object_fixture(hass, "rgb.json", DOMAIN)
+ )
+
+ # Enable keep_main_light so the main light persists even with a single segment
+ hass.config_entries.async_update_entry(
+ init_integration, options={CONF_KEEP_MAIN_LIGHT: True}
+ )
+ await hass.config_entries.async_reload(init_integration.entry_id)
+ await hass.async_block_till_done()
+
+ # 1 segment: group should contain only segment 0
+ assert (state := hass.states.get("light.wled_rgb_light_main"))
+ assert state.state == STATE_ON
+ assert state.attributes[ATTR_GROUP_ENTITIES] == ["light.wled_rgb_light"]
+
+ # Add a second segment
+ mock_wled.update.return_value = two_segment_data
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ # 2 segments: group should contain both
+ assert (state := hass.states.get("light.wled_rgb_light_main"))
+ assert state.attributes[ATTR_GROUP_ENTITIES] == [
+ "light.wled_rgb_light",
+ "light.wled_rgb_light_segment_1",
+ ]
+
+ # Remove the second segment
+ mock_wled.update.return_value = single_segment_data
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ # Back to 1 segment: group should contain only segment 0 again
+ assert (state := hass.states.get("light.wled_rgb_light_main"))
+ assert state.attributes[ATTR_GROUP_ENTITIES] == ["light.wled_rgb_light"]
diff --git a/tests/components/wyoming/snapshots/test_conversation.ambr b/tests/components/wyoming/snapshots/test_conversation.ambr
index 24763cac4416d1..1bb20f8bb8c1c1 100644
--- a/tests/components/wyoming/snapshots/test_conversation.ambr
+++ b/tests/components/wyoming/snapshots/test_conversation.ambr
@@ -1,6 +1,6 @@
# serializer version: 1
# name: test_connection_lost
- 'Connection to service was lost'
+ 'Error communicating with service: Connection lost'
# ---
# name: test_oserror
'Error communicating with service: Boom!'
diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py
index afead7c5a80198..feb9e44a310895 100644
--- a/tests/components/wyoming/test_conversation.py
+++ b/tests/components/wyoming/test_conversation.py
@@ -10,25 +10,47 @@
from wyoming.intent import Entity, Intent, NotRecognized
from homeassistant.components import conversation
+from homeassistant.components.conversation import chat_log
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL
-from homeassistant.core import Context, HomeAssistant
+from homeassistant.core import Context, HomeAssistant, State
from homeassistant.helpers import intent
from . import HANDLE_INFO, INTENT_INFO, MockAsyncTcpClient
+from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401
-async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) -> None:
+
+async def test_intent(
+ hass: HomeAssistant,
+ init_wyoming_intent: ConfigEntry,
+ mock_chat_log: MockChatLog, # noqa: F811
+) -> None:
"""Test when an intent is recognized."""
agent_id = "conversation.test_intent"
- conversation_id = "conversation-1234"
+ conversation_id = mock_chat_log.conversation_id
satellite_id = "satellite-1234"
device_id = "device-1234"
test_intent = Intent(
name="TestIntent",
entities=[Entity(name="entity", value="value")],
- text="success",
+ text="""
+ {# Verify template variables are present #}
+ {% if slots.entity == 'value' %}
+ {% if slots.slot_name == 'slot_value' %}
+ {% if state.entity_id == 'test.matched1' %}
+ {% if query.matched[0].entity_id == 'test.matched1' %}
+ {% if query.unmatched[0].entity_id == 'test.unmatched1' %}
+ {% if query.unmatched[1].entity_id == 'test.unmatched2' %}
+ success
+ {% endif %}
+ {% endif %}
+ {% endif %}
+ {% endif %}
+ {% endif %}
+ {% endif %}
+ """,
)
class TestIntentHandler(intent.IntentHandler):
@@ -41,7 +63,19 @@ async def async_handle(self, intent_obj: intent.Intent):
assert intent_obj.slots.get("entity", {}).get("value") == "value"
assert intent_obj.satellite_id == satellite_id
assert intent_obj.device_id == device_id
- return intent_obj.create_response()
+ response = intent_obj.create_response()
+
+ # Add parts to test response rendering
+ response.async_set_speech_slots({"slot_name": "slot_value"})
+ response.async_set_states(
+ matched_states=[State("test.matched1", "on")],
+ unmatched_states=[
+ State("test.unmatched1", "off"),
+ State("test.unmatched2", "off"),
+ ],
+ )
+
+ return response
intent.async_register(hass, TestIntentHandler())
@@ -67,6 +101,7 @@ async def async_handle(self, intent_obj: intent.Intent):
assert client.transcript.context == {
"conversation_id": conversation_id,
"satellite_id": satellite_id,
+ "device_id": device_id,
}
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
@@ -74,6 +109,21 @@ async def async_handle(self, intent_obj: intent.Intent):
assert result.response.speech.get("plain", {}).get("speech") == "success"
assert result.conversation_id == conversation_id
+ # Verify that chat log recorded intent as tool call
+ content: chat_log.AssistantContent | None = next(
+ filter(
+ lambda c: isinstance(c, chat_log.AssistantContent), mock_chat_log.content
+ ),
+ None,
+ )
+ assert content is not None, "Missing assistant content"
+ assert content.tool_calls and len(content.tool_calls) == 1
+ tool_call = content.tool_calls[0]
+ assert tool_call.tool_name == test_intent.name
+ assert tool_call.tool_args == {
+ e.name: {"value": e.value} for e in test_intent.entities
+ }
+
async def test_intent_handle_error(
hass: HomeAssistant, init_wyoming_intent: ConfigEntry
@@ -141,6 +191,7 @@ async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) ->
agent_id = "conversation.test_handle"
conversation_id = "conversation-1234"
satellite_id = "satellite-1234"
+ device_id = "device-1234"
client = MockAsyncTcpClient([Handled(text="success").event()])
with patch(
@@ -155,6 +206,7 @@ async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) ->
language=hass.config.language,
agent_id=agent_id,
satellite_id=satellite_id,
+ device_id=device_id,
)
# Ensure language and context are sent
@@ -163,6 +215,7 @@ async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) ->
assert client.transcript.context == {
"conversation_id": conversation_id,
"satellite_id": satellite_id,
+ "device_id": device_id,
}
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
diff --git a/tests/conftest.py b/tests/conftest.py
index 6bf2ed1c2c5ce2..c69272922ab8ed 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1972,6 +1972,15 @@ async def mock_enable_bluetooth(
def mock_bluetooth_adapters() -> Generator[None]:
"""Fixture to mock bluetooth adapters."""
with (
+ # Simulate the Bluetooth management API being unavailable, as it is on
+ # CI and most dev machines. Letting the real setup() run would attempt
+ # real socket I/O on Linux hosts that do have BlueZ available, and
+ # mocking it as successful would enable the advertising side channel,
+ # changing the scanner code path the existing tests were written for.
+ patch(
+ "habluetooth.channels.bluez.MGMTBluetoothCtl.setup",
+ AsyncMock(side_effect=OSError),
+ ),
patch("habluetooth.util.recover_adapter"),
patch("bluetooth_auto_recovery.recover_adapter"),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),