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"),