diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 02d5c072efa..03174c0d2b1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -51,6 +51,9 @@ rules: - **Missing imports** - We use static analysis tooling to catch that - **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) +**Git commit practices during review:** +- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review + ## Python Requirements - **Compatibility**: Python 3.13+ diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3b52f22f9eb..3a5a4e91d98 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -15,7 +15,7 @@ env: UV_HTTP_TIMEOUT: 60 UV_SYSTEM_PYTHON: "true" # Base image version from https://github.com/home-assistant/docker - BASE_IMAGE_VERSION: "2025.11.3" + BASE_IMAGE_VERSION: "2025.12.0" ARCHITECTURES: '["amd64", "aarch64"]' jobs: @@ -70,7 +70,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: translations path: translations.tar.gz @@ -169,7 +169,7 @@ jobs: fi - name: Download translations - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: translations @@ -482,7 +482,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: translations @@ -551,7 +551,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf6cf016cbc..d8e2b92c39b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -263,7 +263,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: &actions-cache actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv key: &key-pre-commit-venv >- @@ -304,7 +304,7 @@ jobs: - &cache-restore-pre-commit-venv name: Restore base Python virtual environment id: cache-venv - uses: &actions-cache-restore actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv fail-on-cache-miss: true @@ -511,7 +511,7 @@ jobs: fi - name: Save apt cache if: steps.cache-apt-check.outputs.cache-hit != 'true' - uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: *path-apt-cache key: *key-apt-cache @@ -534,7 +534,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -864,7 +864,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: pytest_buckets - &compile-english-translations @@ -1188,7 +1188,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: fail_ci_if_error: true flags: full-suite @@ -1313,7 +1313,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5e4f37a12e1..2f3c48be0ba 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: category: "/language:python" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index daaa7374713..209f485a80b 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 + - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: github-token: ${{ github.token }} issue-inactive-days: "30" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a6fdcffe74f..fb9dcb62fd5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -74,7 +74,7 @@ jobs: ) > .env_file - name: Upload env_file - uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: env_file path: ./.env_file @@ -119,7 +119,7 @@ jobs: - &download-env-file name: Download env_file - uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: env_file diff --git a/.strict-typing b/.strict-typing index ac0c8c38df5..91d91103c91 100644 --- a/.strict-typing +++ b/.strict-typing @@ -567,6 +567,7 @@ homeassistant.components.wake_word.* homeassistant.components.wallbox.* homeassistant.components.waqi.* homeassistant.components.water_heater.* +homeassistant.components.watts.* homeassistant.components.watttime.* homeassistant.components.weather.* homeassistant.components.webhook.* diff --git a/CODEOWNERS b/CODEOWNERS index aa0c0d105d2..453ffbd73ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -73,6 +73,8 @@ build.json @home-assistant/supervisor /tests/components/airobot/ @mettolen /homeassistant/components/airos/ @CoMPaTech /tests/components/airos/ @CoMPaTech +/homeassistant/components/airpatrol/ @antondalgren +/tests/components/airpatrol/ @antondalgren /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen @LaStrada @@ -218,8 +220,8 @@ build.json @home-assistant/supervisor /homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/blebox/ @bbx-a @swistakm /tests/components/blebox/ @bbx-a @swistakm -/homeassistant/components/blink/ @fronzbot @mkmer -/tests/components/blink/ @fronzbot @mkmer +/homeassistant/components/blink/ @fronzbot +/tests/components/blink/ @fronzbot /homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 /tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 /homeassistant/components/bluemaestro/ @bdraco @@ -306,8 +308,8 @@ build.json @home-assistant/supervisor /tests/components/config/ @home-assistant/core /homeassistant/components/configurator/ @home-assistant/core /tests/components/configurator/ @home-assistant/core -/homeassistant/components/control4/ @lawtancool -/tests/components/control4/ @lawtancool +/homeassistant/components/control4/ @lawtancool @davidrecordon +/tests/components/control4/ @lawtancool @davidrecordon /homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/cookidoo/ @miaucl @@ -462,7 +464,7 @@ build.json @home-assistant/supervisor /tests/components/enigma2/ @autinerd /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac -/homeassistant/components/entur_public_transport/ @hfurubotten +/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 @roberty99 @@ -662,7 +664,8 @@ build.json @home-assistant/supervisor /tests/components/heos/ @andrewsayre /homeassistant/components/here_travel_time/ @eifinger /tests/components/here_travel_time/ @eifinger -/homeassistant/components/hikvision/ @mezz64 +/homeassistant/components/hikvision/ @mezz64 @ptarjan +/tests/components/hikvision/ @mezz64 @ptarjan /homeassistant/components/hikvisioncam/ @fbradyirl /homeassistant/components/hisense_aehw4a1/ @bannhead /tests/components/hisense_aehw4a1/ @bannhead @@ -1192,8 +1195,8 @@ build.json @home-assistant/supervisor /tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl -/homeassistant/components/overseerr/ @joostlek -/tests/components/overseerr/ @joostlek +/homeassistant/components/overseerr/ @joostlek @AmGarera +/tests/components/overseerr/ @joostlek @AmGarera /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas @@ -1795,6 +1798,8 @@ build.json @home-assistant/supervisor /homeassistant/components/watergate/ @adam-the-hero /tests/components/watergate/ @adam-the-hero /homeassistant/components/watson_tts/ @rutkai +/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro +/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya /homeassistant/components/waze_travel_time/ @eifinger diff --git a/Dockerfile b/Dockerfile index 5dd550293f7..6c7dbe6c512 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,13 +24,13 @@ ENV \ COPY rootfs / # Add go2rtc binary -COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc +COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc RUN \ # Verify go2rtc can be executed go2rtc --version \ # Install uv - && pip3 install uv==0.9.6 + && pip3 install uv==0.9.17 WORKDIR /usr/src diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9e375c7fdb8..96af205e4e0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -624,13 +624,16 @@ async def async_enable_logging( if log_file is None: default_log_path = hass.config.path(ERROR_LOG_FILENAME) - if "SUPERVISOR" in os.environ: - _LOGGER.info("Running in Supervisor, not logging to file") + if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ: # Rename the default log file if it exists, since previous versions created # it even on Supervisor - if os.path.isfile(default_log_path): - with contextlib.suppress(OSError): - os.rename(default_log_path, f"{default_log_path}.old") + def rename_old_file() -> None: + """Rename old log file in executor.""" + if os.path.isfile(default_log_path): + with contextlib.suppress(OSError): + os.rename(default_log_path, f"{default_log_path}.old") + + await hass.async_add_executor_job(rename_old_file) err_log_path = None else: err_log_path = default_log_path diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index cfd6814f642..7048e76512f 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -9,15 +9,16 @@ from actron_neo_api import ( from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import _LOGGER +from .const import _LOGGER, DOMAIN from .coordinator import ( ActronAirConfigEntry, ActronAirRuntimeData, ActronAirSystemCoordinator, ) -PLATFORM = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: @@ -29,12 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> try: systems = await api.get_ac_systems() await api.update_status() - except ActronAirAuthError: - _LOGGER.error("Authentication error while setting up Actron Air integration") - raise + except ActronAirAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err except ActronAirAPIError as err: - _LOGGER.error("API error while setting up Actron Air integration: %s", err) - raise + raise ConfigEntryNotReady from err system_coordinators: dict[str, ActronAirSystemCoordinator] = {} for system in systems: @@ -48,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> system_coordinators=system_coordinators, ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORM) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORM) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/actron_air/climate.py b/homeassistant/components/actron_air/climate.py index 38fa4014cb0..6e0e6e0389e 100644 --- a/homeassistant/components/actron_air/climate.py +++ b/homeassistant/components/actron_air/climate.py @@ -148,7 +148,7 @@ class ActronSystemClimate(BaseClimateEntity): @property def fan_mode(self) -> str | None: """Return the current fan mode.""" - fan_mode = self._status.user_aircon_settings.fan_mode + fan_mode = self._status.user_aircon_settings.base_fan_mode return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode) @property diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index aad756ab6dd..d882424ef01 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -1,11 +1,12 @@ """Setup config flow for Actron Air integration.""" import asyncio +from collections.abc import Mapping from typing import Any from actron_neo_api import ActronAirAPI, ActronAirAuthError -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN from homeassistant.exceptions import HomeAssistantError @@ -95,8 +96,16 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): unique_id = str(user_data["id"]) await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() + # Check if this is a reauth flow + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_API_TOKEN: self._api.refresh_token_value}, + ) + + self._abort_if_unique_id_configured() return self.async_create_entry( title=user_data["email"], data={CONF_API_TOKEN: self._api.refresh_token_value}, @@ -114,6 +123,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): del self.login_task return await self.async_step_user() + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication request.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is not None: + return await self.async_step_user() + + return self.async_show_form(step_id="reauth_confirm") + async def async_step_connection_error( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/actron_air/coordinator.py b/homeassistant/components/actron_air/coordinator.py index 27613a61361..6071fe9b8eb 100644 --- a/homeassistant/components/actron_air/coordinator.py +++ b/homeassistant/components/actron_air/coordinator.py @@ -5,16 +5,23 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus +from actron_neo_api import ( + ActronAirACSystem, + ActronAirAPI, + ActronAirAuthError, + ActronAirStatus, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from .const import _LOGGER +from .const import _LOGGER, DOMAIN -STALE_DEVICE_TIMEOUT = timedelta(hours=24) +SCAN_INTERVAL = timedelta(seconds=30) +STALE_DEVICE_TIMEOUT = timedelta(minutes=5) ERROR_NO_SYSTEMS_FOUND = "no_systems_found" ERROR_UNKNOWN = "unknown_error" @@ -29,9 +36,6 @@ class ActronAirRuntimeData: type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData] -AUTH_ERROR_THRESHOLD = 3 -SCAN_INTERVAL = timedelta(seconds=30) - class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): """System coordinator for Actron Air integration.""" @@ -59,7 +63,14 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): async def _async_update_data(self) -> ActronAirStatus: """Fetch updates and merge incremental changes into the full state.""" - await self.api.update_status() + try: + await self.api.update_status() + except ActronAirAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + self.status = self.api.state_manager.get_status(self.serial_number) self.last_seen = dt_util.utcnow() return self.status diff --git a/homeassistant/components/actron_air/icons.json b/homeassistant/components/actron_air/icons.json new file mode 100644 index 00000000000..0716c845104 --- /dev/null +++ b/homeassistant/components/actron_air/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "switch": { + "away_mode": { + "default": "mdi:home-export-outline", + "state": { + "off": "mdi:home-import-outline" + } + }, + "continuous_fan": { + "default": "mdi:fan", + "state": { + "off": "mdi:fan-off" + } + }, + "quiet_mode": { + "default": "mdi:volume-low", + "state": { + "off": "mdi:volume-high" + } + }, + "turbo_mode": { + "default": "mdi:fan-plus", + "state": { + "off": "mdi:fan" + } + } + } + } +} diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 5ab4a7329cc..6fe0f14bb24 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -10,7 +10,8 @@ } ], "documentation": "https://www.home-assistant.io/integrations/actron_air", + "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["actron-neo-api==0.1.87"] + "requirements": ["actron-neo-api==0.4.1"] } diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index 06010dd3ed0..5d1f917da3b 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -36,7 +36,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/actron_air/strings.json b/homeassistant/components/actron_air/strings.json index a89689850bf..b7a94efad0a 100644 --- a/homeassistant/components/actron_air/strings.json +++ b/homeassistant/components/actron_air/strings.json @@ -2,10 +2,12 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "oauth2_error": "Failed to start OAuth2 flow" + "oauth2_error": "Failed to start authentication flow", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured." }, "error": { - "oauth2_error": "Failed to start OAuth2 flow. Please try again later." + "oauth2_error": "Failed to start authentication flow. Please try again later." }, "progress": { "wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes." @@ -16,14 +18,39 @@ "description": "Failed to connect to Actron Air. Please check your internet connection and try again.", "title": "Connection error" }, + "reauth_confirm": { + "description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.", + "title": "Authentication expired" + }, "timeout": { "data": {}, - "description": "The authorization process timed out. Please try again.", - "title": "Authorization timeout" + "description": "The authentication process timed out. Please try again.", + "title": "Authentication timeout" }, "user": { - "title": "Actron Air OAuth2 Authorization" + "title": "Actron Air Authentication" } } + }, + "entity": { + "switch": { + "away_mode": { + "name": "Away mode" + }, + "continuous_fan": { + "name": "Continuous fan" + }, + "quiet_mode": { + "name": "Quiet mode" + }, + "turbo_mode": { + "name": "Turbo mode" + } + } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed, please reauthenticate" + } } } diff --git a/homeassistant/components/actron_air/switch.py b/homeassistant/components/actron_air/switch.py new file mode 100644 index 00000000000..b886d82e5f9 --- /dev/null +++ b/homeassistant/components/actron_air/switch.py @@ -0,0 +1,110 @@ +"""Switch platform for Actron Air integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ActronAirSwitchEntityDescription(SwitchEntityDescription): + """Class describing Actron Air switch entities.""" + + is_on_fn: Callable[[ActronAirSystemCoordinator], bool] + set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]] + is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True + + +SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = ( + ActronAirSwitchEntityDescription( + key="away_mode", + translation_key="away_mode", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled), + ), + ActronAirSwitchEntityDescription( + key="continuous_fan", + translation_key="continuous_fan", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled), + ), + ActronAirSwitchEntityDescription( + key="quiet_mode", + translation_key="quiet_mode", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled), + ), + ActronAirSwitchEntityDescription( + key="turbo_mode", + translation_key="turbo_mode", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled), + is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ActronAirConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Actron Air switch entities.""" + system_coordinators = entry.runtime_data.system_coordinators + async_add_entities( + ActronAirSwitch(coordinator, description) + for coordinator in system_coordinators.values() + for description in SWITCHES + if description.is_supported_fn(coordinator) + ) + + +class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity): + """Actron Air switch.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + entity_description: ActronAirSwitchEntityDescription + + def __init__( + self, + coordinator: ActronAirSystemCoordinator, + description: ActronAirSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.serial_number)}, + manufacturer="Actron Air", + name=coordinator.data.ac_system.system_name, + ) + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.entity_description.is_on_fn(self.coordinator) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.set_fn(self.coordinator, True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.set_fn(self.coordinator, False) diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index 553a641b603..9264bae1489 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Bre77"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/advantage_air", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["advantage_air"], "requirements": ["advantage-air==0.4.4"] diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 24ca0099091..db3ff8c5a69 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Noltari"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aemet", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], "requirements": ["AEMET-OpenData==0.6.4"] diff --git a/homeassistant/components/aftership/manifest.json b/homeassistant/components/aftership/manifest.json index eb4fffa57bc..dc0f458260c 100644 --- a/homeassistant/components/aftership/manifest.json +++ b/homeassistant/components/aftership/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aftership", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["pyaftership==21.11.0"] } diff --git a/homeassistant/components/agent_dvr/manifest.json b/homeassistant/components/agent_dvr/manifest.json index 4ec14296363..e40fa1bc7a4 100644 --- a/homeassistant/components/agent_dvr/manifest.json +++ b/homeassistant/components/agent_dvr/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ispysoftware"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/agent_dvr", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["agent"], "requirements": ["agent-py==0.0.24"] diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index 41df51715fc..da1c936b68f 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@asymworks"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airnow", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyairnow"], "requirements": ["pyairnow==1.3.1"] diff --git a/homeassistant/components/airobot/sensor.py b/homeassistant/components/airobot/sensor.py index a1bebb92324..a8c784ed399 100644 --- a/homeassistant/components/airobot/sensor.py +++ b/homeassistant/components/airobot/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from pyairobotrest.models import ThermostatStatus @@ -23,6 +24,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance from . import AirobotConfigEntry from .entity import AirobotEntity @@ -34,10 +37,15 @@ PARALLEL_UPDATES = 0 class AirobotSensorEntityDescription(SensorEntityDescription): """Describes Airobot sensor entity.""" - value_fn: Callable[[ThermostatStatus], StateType] + value_fn: Callable[[ThermostatStatus], StateType | datetime] supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True +uptime_to_stable_datetime = ignore_variance( + lambda value: utcnow().replace(microsecond=0) - timedelta(seconds=value), + timedelta(minutes=2), +) + SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = ( AirobotSensorEntityDescription( key="air_temperature", @@ -96,6 +104,14 @@ SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda status: status.errors, ), + AirobotSensorEntityDescription( + key="device_uptime", + translation_key="device_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status: uptime_to_stable_datetime(status.device_uptime), + entity_registry_enabled_default=False, + ), ) @@ -129,6 +145,6 @@ class AirobotSensor(AirobotEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}" @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data.status) diff --git a/homeassistant/components/airpatrol/__init__.py b/homeassistant/components/airpatrol/__init__.py new file mode 100644 index 00000000000..6a52f18477a --- /dev/null +++ b/homeassistant/components/airpatrol/__init__.py @@ -0,0 +1,24 @@ +"""The AirPatrol integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import AirPatrolConfigEntry, AirPatrolDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool: + """Set up AirPatrol from a config entry.""" + coordinator = AirPatrolDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airpatrol/climate.py b/homeassistant/components/airpatrol/climate.py new file mode 100644 index 00000000000..711c2655e98 --- /dev/null +++ b/homeassistant/components/airpatrol/climate.py @@ -0,0 +1,198 @@ +"""Climate platform for AirPatrol integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + SWING_OFF, + SWING_ON, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AirPatrolConfigEntry +from .coordinator import AirPatrolDataUpdateCoordinator +from .entity import AirPatrolEntity + +PARALLEL_UPDATES = 0 + +AP_TO_HA_HVAC_MODES = { + "heat": HVACMode.HEAT, + "cool": HVACMode.COOL, + "off": HVACMode.OFF, +} +HA_TO_AP_HVAC_MODES = {value: key for key, value in AP_TO_HA_HVAC_MODES.items()} + +AP_TO_HA_FAN_MODES = { + "min": FAN_LOW, + "max": FAN_HIGH, + "auto": FAN_AUTO, +} +HA_TO_AP_FAN_MODES = {value: key for key, value in AP_TO_HA_FAN_MODES.items()} + +AP_TO_HA_SWING_MODES = { + "on": SWING_ON, + "off": SWING_OFF, +} +HA_TO_AP_SWING_MODES = {value: key for key, value in AP_TO_HA_SWING_MODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirPatrolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AirPatrol climate entities.""" + coordinator = config_entry.runtime_data + units = coordinator.data + + async_add_entities( + AirPatrolClimate(coordinator, unit_id) + for unit_id, unit in units.items() + if "climate" in unit + ) + + +class AirPatrolClimate(AirPatrolEntity, ClimateEntity): + """AirPatrol climate entity.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + _attr_fan_modes = [FAN_LOW, FAN_HIGH, FAN_AUTO] + _attr_swing_modes = [SWING_ON, SWING_OFF] + _attr_min_temp = 16.0 + _attr_max_temp = 30.0 + + def __init__( + self, + coordinator: AirPatrolDataUpdateCoordinator, + unit_id: str, + ) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator, unit_id) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}" + + @property + def params(self) -> dict[str, Any]: + """Return the current parameters for the climate entity.""" + return self.climate_data.get("ParametersData") or {} + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + if humidity := self.climate_data.get("RoomHumidity"): + return float(humidity) + return None + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temp := self.climate_data.get("RoomTemp"): + return float(temp) + return None + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + if temp := self.params.get("PumpTemp"): + return float(temp) + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + pump_power = self.params.get("PumpPower") + pump_mode = self.params.get("PumpMode") + + if pump_power and pump_power == "on" and pump_mode: + return AP_TO_HA_HVAC_MODES.get(pump_mode) + return HVACMode.OFF + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + fan_speed = self.params.get("FanSpeed") + if fan_speed: + return AP_TO_HA_FAN_MODES.get(fan_speed) + return None + + @property + def swing_mode(self) -> str | None: + """Return the current swing mode.""" + swing = self.params.get("Swing") + if swing: + return AP_TO_HA_SWING_MODES.get(swing) + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params = self.params.copy() + + if ATTR_TEMPERATURE in kwargs: + temp = kwargs[ATTR_TEMPERATURE] + params["PumpTemp"] = f"{temp:.3f}" + + await self._async_set_params(params) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + params = self.params.copy() + + if hvac_mode == HVACMode.OFF: + params["PumpPower"] = "off" + else: + params["PumpPower"] = "on" + params["PumpMode"] = HA_TO_AP_HVAC_MODES.get(hvac_mode) + + await self._async_set_params(params) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + params = self.params.copy() + params["FanSpeed"] = HA_TO_AP_FAN_MODES.get(fan_mode) + + await self._async_set_params(params) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing mode.""" + params = self.params.copy() + params["Swing"] = HA_TO_AP_SWING_MODES.get(swing_mode) + + await self._async_set_params(params) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = self.params.copy() + if mode := AP_TO_HA_HVAC_MODES.get(params["PumpMode"]): + await self.async_set_hvac_mode(mode) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def _async_set_params(self, params: dict[str, Any]) -> None: + """Set the unit to dry mode.""" + new_climate_data = self.climate_data.copy() + new_climate_data["ParametersData"] = params + + await self.coordinator.api.set_unit_climate_data( + self._unit_id, new_climate_data + ) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airpatrol/config_flow.py b/homeassistant/components/airpatrol/config_flow.py new file mode 100644 index 00000000000..7d810336676 --- /dev/null +++ b/homeassistant/components/airpatrol/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for the AirPatrol integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +async def validate_api( + hass: HomeAssistant, user_input: dict[str, str] +) -> tuple[str | None, str | None, dict[str, str]]: + """Validate the API connection.""" + errors: dict[str, str] = {} + session = async_get_clientsession(hass) + access_token = None + unique_id = None + try: + api = await AirPatrolAPI.authenticate( + session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except AirPatrolAuthenticationError: + errors["base"] = "invalid_auth" + except AirPatrolError: + errors["base"] = "cannot_connect" + else: + access_token = api.get_access_token() + unique_id = api.get_unique_id() + + return (access_token, unique_id, errors) + + +class AirPatrolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AirPatrol.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + access_token, unique_id, errors = await validate_api(self.hass, user_input) + if access_token and unique_id: + user_input[CONF_ACCESS_TOKEN] = access_token + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication with new credentials.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + + if user_input: + access_token, unique_id, errors = await validate_api(self.hass, user_input) + if access_token and unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch() + user_input[CONF_ACCESS_TOKEN] = access_token + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + return self.async_show_form( + step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airpatrol/const.py b/homeassistant/components/airpatrol/const.py new file mode 100644 index 00000000000..b390f5eec21 --- /dev/null +++ b/homeassistant/components/airpatrol/const.py @@ -0,0 +1,16 @@ +"""Constants for the AirPatrol integration.""" + +from datetime import timedelta +import logging + +from airpatrol.api import AirPatrolAuthenticationError, AirPatrolError + +from homeassistant.const import Platform + +DOMAIN = "airpatrol" + +LOGGER = logging.getLogger(__package__) +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +SCAN_INTERVAL = timedelta(minutes=1) + +AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError) diff --git a/homeassistant/components/airpatrol/coordinator.py b/homeassistant/components/airpatrol/coordinator.py new file mode 100644 index 00000000000..37946c65a3b --- /dev/null +++ b/homeassistant/components/airpatrol/coordinator.py @@ -0,0 +1,100 @@ +"""Data update coordinator for AirPatrol.""" + +from __future__ import annotations + +from typing import Any + +from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type AirPatrolConfigEntry = ConfigEntry[AirPatrolDataUpdateCoordinator] + + +class AirPatrolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching AirPatrol data.""" + + config_entry: AirPatrolConfigEntry + api: AirPatrolAPI + + def __init__(self, hass: HomeAssistant, config_entry: AirPatrolConfigEntry) -> None: + """Initialize.""" + + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN.capitalize()} {config_entry.title}", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + async def _async_setup(self) -> None: + try: + await self._setup_client() + except AirPatrolError as api_err: + raise UpdateFailed( + f"Error communicating with AirPatrol API: {api_err}" + ) from api_err + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Update unit data from AirPatrol API.""" + return {unit_data["unit_id"]: unit_data for unit_data in await self._get_data()} + + async def _get_data(self, retry: bool = False) -> list[dict[str, Any]]: + """Fetch data from API.""" + try: + return await self.api.get_data() + except AirPatrolAuthenticationError as auth_err: + if retry: + raise ConfigEntryAuthFailed( + "Authentication with AirPatrol failed" + ) from auth_err + await self._update_token() + return await self._get_data(retry=True) + except AirPatrolError as err: + raise UpdateFailed( + f"Error communicating with AirPatrol API: {err}" + ) from err + + async def _update_token(self) -> None: + """Refresh the AirPatrol API client and update the access token.""" + session = async_get_clientsession(self.hass) + try: + self.api = await AirPatrolAPI.authenticate( + session, + self.config_entry.data[CONF_EMAIL], + self.config_entry.data[CONF_PASSWORD], + ) + except AirPatrolAuthenticationError as auth_err: + raise ConfigEntryAuthFailed( + "Authentication with AirPatrol failed" + ) from auth_err + + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_ACCESS_TOKEN: self.api.get_access_token(), + }, + ) + + async def _setup_client(self) -> None: + """Set up the AirPatrol API client from stored access_token.""" + session = async_get_clientsession(self.hass) + api = AirPatrolAPI( + session, + self.config_entry.data[CONF_ACCESS_TOKEN], + self.config_entry.unique_id, + ) + try: + await api.get_data() + except AirPatrolAuthenticationError: + await self._update_token() + self.api = api diff --git a/homeassistant/components/airpatrol/entity.py b/homeassistant/components/airpatrol/entity.py new file mode 100644 index 00000000000..0f4e14c0086 --- /dev/null +++ b/homeassistant/components/airpatrol/entity.py @@ -0,0 +1,54 @@ +"""Base entity for AirPatrol integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AirPatrolDataUpdateCoordinator + + +class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]): + """Base entity for AirPatrol devices.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirPatrolDataUpdateCoordinator, + unit_id: str, + ) -> None: + """Initialize the AirPatrol entity.""" + super().__init__(coordinator) + self._unit_id = unit_id + device = coordinator.data[unit_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unit_id)}, + name=device["name"], + manufacturer=device["manufacturer"], + model=device["model"], + serial_number=device["hwid"], + ) + + @property + def device_data(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[self._unit_id] + + @property + def climate_data(self) -> dict[str, Any]: + """Return the climate data for this unit.""" + return self.device_data["climate"] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self._unit_id in self.coordinator.data + and "climate" in self.device_data + and self.climate_data is not None + ) diff --git a/homeassistant/components/airpatrol/manifest.json b/homeassistant/components/airpatrol/manifest.json new file mode 100644 index 00000000000..bda277a8f8f --- /dev/null +++ b/homeassistant/components/airpatrol/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airpatrol", + "name": "AirPatrol", + "codeowners": ["@antondalgren"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airpatrol", + "integration_type": "device", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["airpatrol==0.1.0"] +} diff --git a/homeassistant/components/airpatrol/quality_scale.yaml b/homeassistant/components/airpatrol/quality_scale.yaml new file mode 100644 index 00000000000..af09fd05c8f --- /dev/null +++ b/homeassistant/components/airpatrol/quality_scale.yaml @@ -0,0 +1,65 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities doesn't subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/airpatrol/sensor.py b/homeassistant/components/airpatrol/sensor.py new file mode 100644 index 00000000000..f25c045599a --- /dev/null +++ b/homeassistant/components/airpatrol/sensor.py @@ -0,0 +1,89 @@ +"""Sensors for AirPatrol integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AirPatrolConfigEntry +from .coordinator import AirPatrolDataUpdateCoordinator +from .entity import AirPatrolEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirPatrolSensorEntityDescription(SensorEntityDescription): + """Describes AirPatrol sensor entity.""" + + data_field: str + + +SENSOR_DESCRIPTIONS = ( + AirPatrolSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + data_field="RoomTemp", + ), + AirPatrolSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + data_field="RoomHumidity", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirPatrolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AirPatrol sensors.""" + coordinator = config_entry.runtime_data + units = coordinator.data + + async_add_entities( + AirPatrolSensor(coordinator, unit_id, description) + for unit_id, unit in units.items() + for description in SENSOR_DESCRIPTIONS + if "climate" in unit and unit["climate"] is not None + ) + + +class AirPatrolSensor(AirPatrolEntity, SensorEntity): + """AirPatrol sensor entity.""" + + entity_description: AirPatrolSensorEntityDescription + + def __init__( + self, + coordinator: AirPatrolDataUpdateCoordinator, + unit_id: str, + description: AirPatrolSensorEntityDescription, + ) -> None: + """Initialize AirPatrol sensor.""" + super().__init__(coordinator, unit_id) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}" + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + if value := self.climate_data.get(self.entity_description.data_field): + return float(value) + return None diff --git a/homeassistant/components/airpatrol/strings.json b/homeassistant/components/airpatrol/strings.json new file mode 100644 index 00000000000..126a0ad723a --- /dev/null +++ b/homeassistant/components/airpatrol/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "Login credentials do not match the configured account" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::airpatrol::config::step::user::data_description::email%]", + "password": "[%key:component::airpatrol::config::step::user::data_description::password%]" + }, + "description": "Reauthenticate with AirPatrol" + }, + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Your AirPatrol email address", + "password": "Your AirPatrol password" + }, + "description": "Connect to AirPatrol" + } + } + } +} diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index 5204d7a4ba8..1957c4c4aab 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -17,6 +17,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/airthings", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["airthings"], "requirements": ["airthings-cloud==0.2.0"] diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 06ee91f9ab3..4dd203bd3af 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -27,6 +27,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["airthings-ble==1.2.0"] } diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json index 8a1f947af64..385fd796ebd 100644 --- a/homeassistant/components/airtouch4/manifest.json +++ b/homeassistant/components/airtouch4/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@samsinnamon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch4", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["airtouch4pyapi"], "requirements": ["airtouch4pyapi==1.0.5"] diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json index be1c640cf5d..7c7a1c4dd94 100644 --- a/homeassistant/components/airtouch5/manifest.json +++ b/homeassistant/components/airtouch5/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@danzel"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch5", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["airtouch5py"], "requirements": ["airtouch5py==0.3.0"] diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index bab1909d075..d5aeeb7988d 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -9,6 +9,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/airzone", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairzone"], "requirements": ["aioairzone==1.0.4"] diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index c7a2baa8c37..f893505f024 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Noltari"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], "requirements": ["aioairzone-cloud==0.7.2"] diff --git a/homeassistant/components/alarm_control_panel/trigger.py b/homeassistant/components/alarm_control_panel/trigger.py index 6efd2a492c9..d52f2e3cacd 100644 --- a/homeassistant/components/alarm_control_panel/trigger.py +++ b/homeassistant/components/alarm_control_panel/trigger.py @@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import ( - EntityStateTriggerBase, + EntityTargetStateTriggerBase, Trigger, - make_conditional_entity_state_trigger, - make_entity_state_trigger, + make_entity_target_state_trigger, + make_entity_transition_trigger, ) from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState @@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool return False -class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase): +class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase): """Trigger for entity state changes.""" _required_features: int @@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase): def make_entity_state_trigger_required_features( domain: str, to_state: str, required_features: int -) -> type[EntityStateTriggerBase]: +) -> type[EntityTargetStateTriggerBase]: """Create an entity state trigger class.""" class CustomTrigger(EntityStateTriggerRequiredFeatures): @@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features( TRIGGERS: dict[str, type[Trigger]] = { - "armed": make_conditional_entity_state_trigger( + "armed": make_entity_transition_trigger( DOMAIN, from_states={ AlarmControlPanelState.ARMING, @@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = { AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelEntityFeature.ARM_VACATION, ), - "disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED), - "triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED), + "disarmed": make_entity_target_state_trigger( + DOMAIN, AlarmControlPanelState.DISARMED + ), + "triggered": make_entity_target_state_trigger( + DOMAIN, AlarmControlPanelState.TRIGGERED + ), } diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json index 401eb1629a1..9414929ea01 100644 --- a/homeassistant/components/amberelectric/manifest.json +++ b/homeassistant/components/amberelectric/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@madpilot"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/amberelectric", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["amberelectric"], "requirements": ["amberelectric==2.0.12"] diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json index d7a9f8ad97a..7f086b64a5b 100644 --- a/homeassistant/components/android_ip_webcam/manifest.json +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@engrbm87"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["pydroid-ipcam==3.0.0"] } diff --git a/homeassistant/components/anglian_water/coordinator.py b/homeassistant/components/anglian_water/coordinator.py index 20eee42b747..81c845420a6 100644 --- a/homeassistant/components/anglian_water/coordinator.py +++ b/homeassistant/components/anglian_water/coordinator.py @@ -4,13 +4,28 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from pyanglianwater import AnglianWater from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import VolumeConverter from .const import CONF_ACCOUNT_NUMBER, DOMAIN @@ -44,6 +59,107 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Update data from Anglian Water's API.""" try: - return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER]) + await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER]) + await self._insert_statistics() except (ExpiredAccessTokenError, UnknownEndpointError) as err: raise UpdateFailed from err + + async def _insert_statistics(self) -> None: + """Insert statistics for water meters into Home Assistant.""" + for meter in self.api.meters.values(): + id_prefix = ( + f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}" + ) + usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower() + _LOGGER.debug("Updating statistics for meter %s", meter.serial_number) + name_prefix = ( + f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} " + f"{meter.serial_number}" + ) + usage_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} Usage", + source=DOMAIN, + statistic_id=usage_statistic_id, + unit_class=VolumeConverter.UNIT_CLASS, + unit_of_measurement=UnitOfVolume.CUBIC_METERS, + ) + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, usage_statistic_id, True, set() + ) + if not last_stat: + _LOGGER.debug("Updating statistics for the first time") + usage_sum = 0.0 + last_stats_time = None + else: + if not meter.readings or len(meter.readings) == 0: + _LOGGER.debug("No recent usage statistics found, skipping update") + continue + # Anglian Water stats are hourly, the read_at time is the time that the meter took the reading + # We remove 1 hour from this so that the data is shown in the correct hour on the dashboards + parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"]) + if not parsed_read_at: + _LOGGER.debug( + "Could not parse read_at time %s, skipping update", + meter.readings[0]["read_at"], + ) + continue + start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) + _LOGGER.debug("Getting statistics at %s", start) + for end in (start + timedelta(seconds=1), None): + stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + start, + end, + { + usage_statistic_id, + }, + "hour", + None, + {"sum"}, + ) + if stats: + break + if end: + _LOGGER.debug( + "Not found, trying to find oldest statistic after %s", + start, + ) + assert stats + + def _safe_get_sum(records: list[Any]) -> float: + if records and "sum" in records[0]: + return float(records[0]["sum"]) + return 0.0 + + usage_sum = _safe_get_sum(stats.get(usage_statistic_id, [])) + last_stats_time = stats[usage_statistic_id][0]["start"] + + usage_statistics = [] + + for read in meter.readings: + parsed_read_at = dt_util.parse_datetime(read["read_at"]) + if not parsed_read_at: + _LOGGER.debug( + "Could not parse read_at time %s, skipping reading", + read["read_at"], + ) + continue + start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) + if last_stats_time is not None and start.timestamp() <= last_stats_time: + continue + usage_state = max(0, read["consumption"] / 1000) + usage_sum = max(0, read["read"]) + usage_statistics.append( + StatisticData( + start=start, + state=usage_state, + sum=usage_sum, + ) + ) + _LOGGER.debug( + "Adding %s statistics for %s", len(usage_statistics), usage_statistic_id + ) + async_add_external_statistics(self.hass, usage_metadata, usage_statistics) diff --git a/homeassistant/components/anglian_water/manifest.json b/homeassistant/components/anglian_water/manifest.json index 2dd5882ab4a..b6f2dd33838 100644 --- a/homeassistant/components/anglian_water/manifest.json +++ b/homeassistant/components/anglian_water/manifest.json @@ -1,11 +1,13 @@ { "domain": "anglian_water", "name": "Anglian Water", + "after_dependencies": ["recorder"], "codeowners": ["@pantherale0"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/anglian_water", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyanglianwater"], "quality_scale": "bronze", - "requirements": ["pyanglianwater==3.0.0"] + "requirements": ["pyanglianwater==3.1.0"] } diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index 7e032f0e361..0e4e1c352f4 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Lash-L"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/anova", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["anova_wifi"], "requirements": ["anova-wifi==0.17.0"] diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json index 5d46266f8ce..a761e81eaf8 100644 --- a/homeassistant/components/anthemav/manifest.json +++ b/homeassistant/components/anthemav/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@hyralex"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/anthemav", + "integration_type": "device", "iot_class": "local_push", "loggers": ["anthemav"], "requirements": ["anthemav==1.4.1"] diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 66ade296440..80ed7d41228 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bdr99"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["py-aosmith==1.0.15"] } diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index e0aff037d9e..785874bd1dc 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@yuxincs"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apcupsd", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["apcaccess"], "quality_scale": "platinum", diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index eb8764e1596..ae1f86c6d35 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@elupus"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["arcam"], "requirements": ["arcam-fmj==1.8.2"], diff --git a/homeassistant/components/arve/manifest.json b/homeassistant/components/arve/manifest.json index 4c63d377371..177fa894447 100644 --- a/homeassistant/components/arve/manifest.json +++ b/homeassistant/components/arve/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ikalnyi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/arve", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["asyncarve==0.1.1"] } diff --git a/homeassistant/components/aseko_pool_live/manifest.json b/homeassistant/components/aseko_pool_live/manifest.json index 628a9732188..25bf3e1c7c7 100644 --- a/homeassistant/components/aseko_pool_live/manifest.json +++ b/homeassistant/components/aseko_pool_live/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@milanmeu"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aseko_pool_live", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioaseko"], "requirements": ["aioaseko==1.0.0"] diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 8c968b83860..dd9e426830b 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pysilero-vad==3.0.0", "pyspeex-noise==1.0.2"] + "requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"] } diff --git a/homeassistant/components/assist_satellite/trigger.py b/homeassistant/components/assist_satellite/trigger.py index 6f2584224ea..31dc212ac96 100644 --- a/homeassistant/components/assist_satellite/trigger.py +++ b/homeassistant/components/assist_satellite/trigger.py @@ -1,16 +1,22 @@ """Provides triggers for assist satellites.""" from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger from .const import DOMAIN from .entity import AssistSatelliteState TRIGGERS: dict[str, type[Trigger]] = { - "idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE), - "listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING), - "processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING), - "responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING), + "idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE), + "listening": make_entity_target_state_trigger( + DOMAIN, AssistSatelliteState.LISTENING + ), + "processing": make_entity_target_state_trigger( + DOMAIN, AssistSatelliteState.PROCESSING + ), + "responding": make_entity_target_state_trigger( + DOMAIN, AssistSatelliteState.RESPONDING + ), } diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 1ebeb596044..bbc8b2675ae 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], - "requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.1"] + "requirements": ["aioasuswrt==1.5.4", "asusrouter==1.21.3"] } diff --git a/homeassistant/components/atag/manifest.json b/homeassistant/components/atag/manifest.json index c45d8c42546..4939cfba84f 100644 --- a/homeassistant/components/atag/manifest.json +++ b/homeassistant/components/atag/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@MatsNL"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/atag", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyatag"], "requirements": ["pyatag==0.3.5.3"] diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index ad2e9767cfb..5cf36392479 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -27,6 +27,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/august", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"] diff --git a/homeassistant/components/aurora/manifest.json b/homeassistant/components/aurora/manifest.json index d94707bfa81..c73f98d9b4f 100644 --- a/homeassistant/components/aurora/manifest.json +++ b/homeassistant/components/aurora/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@djtimca"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aurora", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["auroranoaa"], "requirements": ["auroranoaa==0.0.5"] diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index ea402f03b0e..d059fc06d4e 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@nickw444", "@Bre77"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aussiebb"], "requirements": ["pyaussiebb==0.1.5"] diff --git a/homeassistant/components/autarco/manifest.json b/homeassistant/components/autarco/manifest.json index 6f86f5b84e3..a58fbfda64b 100644 --- a/homeassistant/components/autarco/manifest.json +++ b/homeassistant/components/autarco/manifest.json @@ -4,6 +4,8 @@ "codeowners": ["@klaasnicolaas"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/autarco", + "integration_type": "hub", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["autarco==3.2.0"] } diff --git a/homeassistant/components/autarco/quality_scale.yaml b/homeassistant/components/autarco/quality_scale.yaml index d2e1455af7e..df1b982be10 100644 --- a/homeassistant/components/autarco/quality_scale.yaml +++ b/homeassistant/components/autarco/quality_scale.yaml @@ -6,10 +6,7 @@ rules: This integration does not provide additional actions. appropriate-polling: done brands: done - common-modules: - status: todo - comment: | - The entity.py file is not used in this integration. + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done diff --git a/homeassistant/components/autarco/sensor.py b/homeassistant/components/autarco/sensor.py index 1635adefdb8..292dfa93644 100644 --- a/homeassistant/components/autarco/sensor.py +++ b/homeassistant/components/autarco/sensor.py @@ -204,13 +204,25 @@ async def async_setup_entry( async_add_entities(entities) -class AutarcoBatterySensorEntity( - CoordinatorEntity[AutarcoDataUpdateCoordinator], SensorEntity -): +class AutarcoSensorBase(CoordinatorEntity[AutarcoDataUpdateCoordinator], SensorEntity): + """Base class for Autarco sensors.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AutarcoDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize Autarco sensor base.""" + super().__init__(coordinator) + self.entity_description = description + + +class AutarcoBatterySensorEntity(AutarcoSensorBase): """Defines an Autarco battery sensor.""" entity_description: AutarcoBatterySensorEntityDescription - _attr_has_entity_name = True def __init__( self, @@ -218,10 +230,8 @@ class AutarcoBatterySensorEntity( coordinator: AutarcoDataUpdateCoordinator, description: AutarcoBatterySensorEntityDescription, ) -> None: - """Initialize Autarco sensor.""" - super().__init__(coordinator) - - self.entity_description = description + """Initialize Autarco battery sensor.""" + super().__init__(coordinator, description) self._attr_unique_id = ( f"{coordinator.account_site.site_id}_battery_{description.key}" ) @@ -239,13 +249,10 @@ class AutarcoBatterySensorEntity( return self.entity_description.value_fn(self.coordinator.data.battery) -class AutarcoSolarSensorEntity( - CoordinatorEntity[AutarcoDataUpdateCoordinator], SensorEntity -): +class AutarcoSolarSensorEntity(AutarcoSensorBase): """Defines an Autarco solar sensor.""" entity_description: AutarcoSolarSensorEntityDescription - _attr_has_entity_name = True def __init__( self, @@ -253,10 +260,8 @@ class AutarcoSolarSensorEntity( coordinator: AutarcoDataUpdateCoordinator, description: AutarcoSolarSensorEntityDescription, ) -> None: - """Initialize Autarco sensor.""" - super().__init__(coordinator) - - self.entity_description = description + """Initialize Autarco solar sensor.""" + super().__init__(coordinator, description) self._attr_unique_id = ( f"{coordinator.account_site.site_id}_solar_{description.key}" ) @@ -273,13 +278,10 @@ class AutarcoSolarSensorEntity( return self.entity_description.value_fn(self.coordinator.data.solar) -class AutarcoInverterSensorEntity( - CoordinatorEntity[AutarcoDataUpdateCoordinator], SensorEntity -): +class AutarcoInverterSensorEntity(AutarcoSensorBase): """Defines an Autarco inverter sensor.""" entity_description: AutarcoInverterSensorEntityDescription - _attr_has_entity_name = True def __init__( self, @@ -288,10 +290,8 @@ class AutarcoInverterSensorEntity( description: AutarcoInverterSensorEntityDescription, serial_number: str, ) -> None: - """Initialize Autarco sensor.""" - super().__init__(coordinator) - - self.entity_description = description + """Initialize Autarco inverter sensor.""" + super().__init__(coordinator, description) self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_{description.key}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 149ea8b4b07..c87d8836cfe 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -125,13 +125,17 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "alarm_control_panel", "assist_satellite", "binary_sensor", + "button", "climate", "cover", + "device_tracker", "fan", "lawn_mower", "light", "media_player", + "switch", "text", + "update", "vacuum", } diff --git a/homeassistant/components/azure_data_explorer/manifest.json b/homeassistant/components/azure_data_explorer/manifest.json index 23cc18f5d0d..29ca4998175 100644 --- a/homeassistant/components/azure_data_explorer/manifest.json +++ b/homeassistant/components/azure_data_explorer/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@kaareseras"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/azure_data_explorer", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["azure"], "requirements": ["azure-kusto-ingest==4.5.1", "azure-kusto-data[aio]==4.5.1"] diff --git a/homeassistant/components/azure_devops/manifest.json b/homeassistant/components/azure_devops/manifest.json index 531c9dac48e..22d1514883c 100644 --- a/homeassistant/components/azure_devops/manifest.json +++ b/homeassistant/components/azure_devops/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@timmo001"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/azure_devops", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioazuredevops"], "requirements": ["aioazuredevops==2.2.2"] diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json index 45fbf8c4a56..7f6d72fe884 100644 --- a/homeassistant/components/azure_event_hub/manifest.json +++ b/homeassistant/components/azure_event_hub/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@eavanvalkenburg"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/azure_event_hub", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["azure"], "requirements": ["azure-eventhub==5.11.1"], diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 3e73ab67657..4f4ad797a80 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bdraco", "@jfroy"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", + "integration_type": "device", "iot_class": "local_push", "requirements": ["aiobafi6==0.9.0"], "zeroconf": [ diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index 38d32adc4af..021320456ba 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -12,6 +12,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/balboa", + "integration_type": "device", "iot_class": "local_push", "loggers": ["pybalboa"], "requirements": ["pybalboa==1.1.3"] diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 2ae03896082..5dce19cf8cb 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -22,6 +22,7 @@ class BeoSource: NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio") SPDIF: Final[Source] = Source(name="Optical", id="spdif") TIDAL: Final[Source] = Source(name="Tidal", id="tidal") + TV: Final[Source] = Source(name="TV", id="tv") UNKNOWN: Final[Source] = Source(name="Unknown Source", id="unknown") URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer") @@ -32,7 +33,7 @@ BEO_STATES: dict[str, MediaPlayerState] = { "buffering": MediaPlayerState.PLAYING, "idle": MediaPlayerState.IDLE, "paused": MediaPlayerState.PAUSED, - "stopped": MediaPlayerState.PAUSED, + "stopped": MediaPlayerState.IDLE, "ended": MediaPlayerState.PAUSED, "error": MediaPlayerState.IDLE, # A device's initial state is "unknown" and should be treated as "idle" @@ -55,12 +56,13 @@ BEO_REPEAT_TO_HA: dict[str, RepeatMode] = { class BeoMediaType(StrEnum): """Bang & Olufsen specific media types.""" - FAVOURITE = "favourite" DEEZER = "deezer" + FAVOURITE = "favourite" + OVERLAY_TTS = "overlay_tts" RADIO = "radio" TIDAL = "tidal" TTS = "provider" - OVERLAY_TTS = "overlay_tts" + TV = "tv" class BeoModel(StrEnum): diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index bd1f1178053..e7a958502e8 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -218,6 +218,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity): self._sources: dict[str, str] = {} self._state: str = MediaPlayerState.IDLE self._video_sources: dict[str, str] = {} + self._video_source_id_map: dict[str, str] = {} self._sound_modes: dict[str, int] = {} # Beolink compatible sources @@ -355,6 +356,9 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity): and menu_item.label != "TV" ): self._video_sources[key] = menu_item.label + self._video_source_id_map[ + menu_item.content.content_uri.removeprefix("tv://") + ] = menu_item.label # Combine the source dicts self._sources = self._audio_sources | self._video_sources @@ -627,10 +631,11 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity): def media_content_type(self) -> MediaType | str | None: """Return the current media type.""" content_type = { - BeoSource.URI_STREAMER.id: MediaType.URL, BeoSource.DEEZER.id: BeoMediaType.DEEZER, - BeoSource.TIDAL.id: BeoMediaType.TIDAL, BeoSource.NET_RADIO.id: BeoMediaType.RADIO, + BeoSource.TIDAL.id: BeoMediaType.TIDAL, + BeoSource.TV.id: BeoMediaType.TV, + BeoSource.URI_STREAMER.id: MediaType.URL, } # Hard to determine content type. if self._source_change.id in content_type: @@ -690,7 +695,11 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity): @property def source(self) -> str | None: - """Return the current audio source.""" + """Return the current audio/video source.""" + # Associate TV content ID with a video source + if self.media_content_id in self._video_source_id_map: + return self._video_source_id_map[self.media_content_id] + return self._source_change.name @property diff --git a/homeassistant/components/binary_sensor/trigger.py b/homeassistant/components/binary_sensor/trigger.py index e7b614ebdf1..b63c1420c98 100644 --- a/homeassistant/components/binary_sensor/trigger.py +++ b/homeassistant/components/binary_sensor/trigger.py @@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import get_device_class -from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger +from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger from homeassistant.helpers.typing import UNDEFINED, UndefinedType from . import DOMAIN, BinarySensorDeviceClass @@ -20,7 +20,7 @@ def get_device_class_or_undefined( return UNDEFINED -class BinarySensorOnOffTrigger(EntityStateTriggerBase): +class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase): """Class for binary sensor on/off triggers.""" _device_class: BinarySensorDeviceClass | None diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index a0f4b0c383c..b33e2508b59 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -2,6 +2,7 @@ "domain": "blackbird", "name": "Monoprice Blackbird Matrix Switch", "codeowners": [], + "disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.", "documentation": "https://www.home-assistant.io/integrations/blackbird", "iot_class": "local_polling", "loggers": ["pyblackbird"], diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 83ec27f6eef..19a8a06c835 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bbx-a", "@swistakm"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], "requirements": ["blebox-uniapi==2.5.0"], diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index f903065a124..3519766e0bf 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -64,6 +64,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BlinkConfigEntry) -> b if entry.version == 2: await _reauth_flow_wrapper(hass, entry, data) return False + if entry.version == 3: + # Migrate device_id to hardware_id for blinkpy 0.25.x OAuth2 compatibility + if "device_id" in data: + data["hardware_id"] = data.pop("device_id") + hass.config_entries.async_update_entry(entry, data=data, version=4) + return True return True diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index f4d393ed8b5..896226327af 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEVICE_ID, DOMAIN +from .const import DOMAIN, HARDWARE_ID _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def _send_blink_2fa_pin(blink: Blink, pin: str | None) -> bool: class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Blink config flow.""" - VERSION = 3 + VERSION = 4 def __init__(self) -> None: """Initialize the blink flow.""" @@ -53,7 +53,7 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): async def _handle_user_input(self, user_input: dict[str, Any]): """Handle user input.""" self.auth = Auth( - {**user_input, "device_id": DEVICE_ID}, + {**user_input, "hardware_id": HARDWARE_ID}, no_prompt=True, session=async_get_clientsession(self.hass), ) diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 3e4ffeeea07..e57a05822e4 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "blink" -DEVICE_ID = "Home Assistant" +HARDWARE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 6a596fb5f2f..9dd3b5493c9 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -1,7 +1,7 @@ { "domain": "blink", "name": "Blink", - "codeowners": ["@fronzbot", "@mkmer"], + "codeowners": ["@fronzbot"], "config_flow": true, "dhcp": [ { @@ -18,7 +18,8 @@ } ], "documentation": "https://www.home-assistant.io/integrations/blink", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.24.1"] + "requirements": ["blinkpy==0.25.2"] } diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 5d066968873..73255ae7669 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -13,32 +13,25 @@ from bluecurrent_api.exceptions import ( RequestLimitReached, WebsocketError, ) -import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - ServiceValidationError, -) -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( - BCU_APP, CHARGEPOINT_SETTINGS, CHARGEPOINT_STATUS, - CHARGING_CARD_ID, DOMAIN, EVSE_ID, LOGGER, PLUG_AND_CHARGE, - SERVICE_START_CHARGE_SESSION, VALUE, ) +from .services import async_setup_services type BlueCurrentConfigEntry = ConfigEntry[Connector] @@ -54,13 +47,12 @@ VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema( - { - vol.Required(CONF_DEVICE_ID): cv.string, - # When no charging card is provided, use no charging card (BCU_APP = no charging card). - vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string, - } -) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Blue Current.""" + + async_setup_services(hass) + return True async def async_setup_entry( @@ -88,66 +80,6 @@ async def async_setup_entry( return True -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Blue Current.""" - - async def start_charge_session(service_call: ServiceCall) -> None: - """Start a charge session with the provided device and charge card ID.""" - # When no charge card is provided, use the default charge card set in the config flow. - charging_card_id = service_call.data[CHARGING_CARD_ID] - device_id = service_call.data[CONF_DEVICE_ID] - - # Get the device based on the given device ID. - device = dr.async_get(hass).devices.get(device_id) - - if device is None: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="invalid_device_id" - ) - - blue_current_config_entry: ConfigEntry | None = None - - for config_entry_id in device.config_entries: - config_entry = hass.config_entries.async_get_entry(config_entry_id) - if not config_entry or config_entry.domain != DOMAIN: - # Not the blue_current config entry. - continue - - if config_entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="config_entry_not_loaded" - ) - - blue_current_config_entry = config_entry - break - - if not blue_current_config_entry: - # The device is not connected to a valid blue_current config entry. - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="no_config_entry" - ) - - connector = blue_current_config_entry.runtime_data - - # Get the evse_id from the identifier of the device. - evse_id = next( - identifier[1] - for identifier in device.identifiers - if identifier[0] == DOMAIN - ) - - await connector.client.start_session(evse_id, charging_card_id) - - hass.services.async_register( - DOMAIN, - SERVICE_START_CHARGE_SESSION, - start_charge_session, - SERVICE_START_CHARGE_SESSION_SCHEMA, - ) - - return True - - async def async_unload_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry ) -> bool: diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index a92c8128fc9..c607f28a179 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blue_current", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["bluecurrent_api"], "requirements": ["bluecurrent-api==1.3.2"] diff --git a/homeassistant/components/blue_current/services.py b/homeassistant/components/blue_current/services.py new file mode 100644 index 00000000000..8cd133ccdef --- /dev/null +++ b/homeassistant/components/blue_current/services.py @@ -0,0 +1,79 @@ +"""The Blue Current integration.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import BCU_APP, CHARGING_CARD_ID, DOMAIN, SERVICE_START_CHARGE_SESSION + +SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + # When no charging card is provided, use no charging card (BCU_APP = no charging card). + vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string, + } +) + + +async def start_charge_session(service_call: ServiceCall) -> None: + """Start a charge session with the provided device and charge card ID.""" + # When no charge card is provided, use the default charge card set in the config flow. + charging_card_id = service_call.data[CHARGING_CARD_ID] + device_id = service_call.data[CONF_DEVICE_ID] + + # Get the device based on the given device ID. + device = dr.async_get(service_call.hass).devices.get(device_id) + + if device is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_device_id" + ) + + blue_current_config_entry: ConfigEntry | None = None + + for config_entry_id in device.config_entries: + config_entry = service_call.hass.config_entries.async_get_entry(config_entry_id) + if not config_entry or config_entry.domain != DOMAIN: + # Not the blue_current config entry. + continue + + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="config_entry_not_loaded" + ) + + blue_current_config_entry = config_entry + break + + if not blue_current_config_entry: + # The device is not connected to a valid blue_current config entry. + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="no_config_entry" + ) + + connector = blue_current_config_entry.runtime_data + + # Get the evse_id from the identifier of the device. + evse_id = next( + identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN + ) + + await connector.client.start_session(evse_id, charging_card_id) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the services.""" + + hass.services.async_register( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + start_charge_session, + SERVICE_START_CHARGE_SESSION_SCHEMA, + ) diff --git a/homeassistant/components/bluemaestro/manifest.json b/homeassistant/components/bluemaestro/manifest.json index 422829fdda4..83407927772 100644 --- a/homeassistant/components/bluemaestro/manifest.json +++ b/homeassistant/components/bluemaestro/manifest.json @@ -11,6 +11,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bluemaestro", + "integration_type": "device", "iot_class": "local_push", "requirements": ["bluemaestro-ble==0.4.1"] } diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index f4e49e00175..53109568fb5 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@thrawnarn", "@LouisChrist"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["pyblu==2.0.5"], "zeroconf": [ diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 327b47bbea2..e23c710b869 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "requirements": ["bimmer-connected[china]==0.17.3"] diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 704b9934970..73ef7441327 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -14,6 +14,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/bond", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["bond_async"], "requirements": ["bond-async==0.2.1"], diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index c645c5dcc01..a4a069772a4 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@tschamm"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bosch_shc", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["boschshcpy"], "requirements": ["boschshcpy==0.2.107"], diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index 0a386094bae..c7f7216b9a2 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["brottsplatskartan"], "requirements": ["brottsplatskartan==1.0.5"] diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json index cf82dad35da..05c9442065b 100644 --- a/homeassistant/components/brunt/manifest.json +++ b/homeassistant/components/brunt/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@eavanvalkenburg"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brunt", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["brunt"], "requirements": ["brunt==1.2.0"] diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index def54802ff8..5e12600b4b1 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==3.1.3"], + "requirements": ["python-bsblan==3.1.4"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index 5b08f5c631a..0014af43848 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@mjj4791", "@ties", "@Robbie1221"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/buienradar", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["buienradar", "vincenty"], "requirements": ["buienradar==1.0.6"] diff --git a/homeassistant/components/button/icons.json b/homeassistant/components/button/icons.json index 13753032275..59dc6e0cae0 100644 --- a/homeassistant/components/button/icons.json +++ b/homeassistant/components/button/icons.json @@ -17,5 +17,10 @@ "press": { "service": "mdi:gesture-tap-button" } + }, + "triggers": { + "pressed": { + "trigger": "mdi:gesture-tap-button" + } } } diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index 02baad6bdcb..3b0a5d504d2 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -27,5 +27,11 @@ "name": "Press" } }, - "title": "Button" + "title": "Button", + "triggers": { + "pressed": { + "description": "Triggers when a button was pressed", + "name": "Button pressed" + } + } } diff --git a/homeassistant/components/button/trigger.py b/homeassistant/components/button/trigger.py new file mode 100644 index 00000000000..5b9e2904dd1 --- /dev/null +++ b/homeassistant/components/button/trigger.py @@ -0,0 +1,42 @@ +"""Provides triggers for buttons.""" + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + EntityTriggerBase, + Trigger, +) + +from . import DOMAIN + + +class ButtonPressedTrigger(EntityTriggerBase): + """Trigger for button entity presses.""" + + _domain = DOMAIN + _schema = ENTITY_STATE_TRIGGER_SCHEMA + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is valid and different from the current state.""" + + # UNKNOWN is a valid from_state, otherwise the first time the button is pressed + # would not trigger + if from_state.state == STATE_UNAVAILABLE: + return False + + return from_state.state != to_state.state + + def is_valid_state(self, state: State) -> bool: + """Check if the new state is not invalid.""" + return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + +TRIGGERS: dict[str, type[Trigger]] = { + "pressed": ButtonPressedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for buttons.""" + return TRIGGERS diff --git a/homeassistant/components/button/triggers.yaml b/homeassistant/components/button/triggers.yaml new file mode 100644 index 00000000000..520a0bc1f20 --- /dev/null +++ b/homeassistant/components/button/triggers.yaml @@ -0,0 +1,4 @@ +pressed: + target: + entity: + domain: button diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 2d6a25114c3..794754e657c 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/caldav", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], "requirements": ["caldav==2.1.0", "icalendar==6.3.1", "vobject==0.9.9"] diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json index 9383bc91556..6b2f17ebc26 100644 --- a/homeassistant/components/canary/manifest.json +++ b/homeassistant/components/canary/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/canary", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["canary"], "requirements": ["py-canary==0.5.4"], diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json index 23cd5547963..957eb47b1ce 100644 --- a/homeassistant/components/ccm15/manifest.json +++ b/homeassistant/components/ccm15/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ocalvo"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ccm15", + "integration_type": "hub", "iot_class": "local_polling", "requirements": ["py_ccm15==0.1.2"] } diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json index df135b65bbe..d5b538b239d 100644 --- a/homeassistant/components/cert_expiry/manifest.json +++ b/homeassistant/components/cert_expiry/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@jjlawren"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cert_expiry", + "integration_type": "service", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index a8699a4ab47..db7570030fb 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -3,22 +3,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import ( Trigger, - make_conditional_entity_state_trigger, - make_entity_state_attribute_trigger, - make_entity_state_trigger, + make_entity_target_state_attribute_trigger, + make_entity_target_state_trigger, + make_entity_transition_trigger, ) from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode TRIGGERS: dict[str, type[Trigger]] = { - "started_cooling": make_entity_state_attribute_trigger( + "started_cooling": make_entity_target_state_attribute_trigger( DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING ), - "started_drying": make_entity_state_attribute_trigger( + "started_drying": make_entity_target_state_attribute_trigger( DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING ), - "turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF), - "turned_on": make_conditional_entity_state_trigger( + "turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF), + "turned_on": make_entity_transition_trigger( DOMAIN, from_states={ HVACMode.OFF, @@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = { HVACMode.HEAT_COOL, }, ), - "started_heating": make_entity_state_attribute_trigger( + "started_heating": make_entity_target_state_attribute_trigger( DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING ), } diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index 8529a0b9bad..76fb8ee0f31 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ludeeus", "@ctalkington"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cloudflare", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["pycfdns"], "requirements": ["pycfdns==3.0.0"], diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index fcd48f9e91d..28e70c85dd5 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tombrien"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coinbase", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["coinbase"], "requirements": ["coinbase-advanced-py==1.2.2"] diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 775adb6a7d5..11f04eb9951 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -1,157 +1,19 @@ """Module for color_extractor (RGB extraction from images) component.""" -import asyncio -import io -import logging - -import aiohttp -from colorthief import ColorThief -from PIL import UnidentifiedImageError -import voluptuous as vol - -from homeassistant.components.light import ( - ATTR_RGB_COLOR, - DOMAIN as LIGHT_DOMAIN, - LIGHT_TURN_ON_SCHEMA, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .services import async_setup_services CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -# Extend the existing light.turn_on service schema -SERVICE_SCHEMA = vol.All( - cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), - cv.make_entity_service_schema( - { - **LIGHT_TURN_ON_SCHEMA, - vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile, - vol.Exclusive(ATTR_URL, "color_extractor"): cv.url, - } - ), -) - - -def _get_file(file_path): - """Get a PIL acceptable input file reference. - - Allows us to mock patch during testing to make BytesIO stream. - """ - return file_path - - -def _get_color(file_handler) -> tuple: - """Given an image file, extract the predominant color from it.""" - color_thief = ColorThief(file_handler) - - # get_color returns a SINGLE RGB value for the given image - color = color_thief.get_color(quality=1) - - _LOGGER.debug("Extracted RGB color %s from image", color) - - return color - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Color extractor component.""" - - async def async_handle_service(service_call: ServiceCall) -> None: - """Decide which color_extractor method to call based on service.""" - service_data = dict(service_call.data) - - try: - if ATTR_URL in service_data: - image_type = "URL" - image_reference = service_data.pop(ATTR_URL) - color = await async_extract_color_from_url(image_reference) - - elif ATTR_PATH in service_data: - image_type = "file path" - image_reference = service_data.pop(ATTR_PATH) - color = await hass.async_add_executor_job( - extract_color_from_path, image_reference - ) - - except UnidentifiedImageError as ex: - _LOGGER.error( - "Bad image from %s '%s' provided, are you sure it's an image? %s", - image_type, - image_reference, - ex, - ) - return - - if color: - service_data[ATTR_RGB_COLOR] = color - - await hass.services.async_call( - LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True - ) - - hass.services.async_register( - DOMAIN, - SERVICE_TURN_ON, - async_handle_service, - schema=SERVICE_SCHEMA, - ) - - async def async_extract_color_from_url(url): - """Handle call for URL based image.""" - if not hass.config.is_allowed_external_url(url): - _LOGGER.error( - ( - "External URL '%s' is not allowed, please add to" - " 'allowlist_external_urls'" - ), - url, - ) - return None - - _LOGGER.debug("Getting predominant RGB from image URL '%s'", url) - - # Download the image into a buffer for ColorThief to check against - try: - session = aiohttp_client.async_get_clientsession(hass) - - async with asyncio.timeout(10): - response = await session.get(url) - - except (TimeoutError, aiohttp.ClientError) as err: - _LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err) - return None - - content = await response.content.read() - - with io.BytesIO(content) as _file: - _file.name = "color_extractor.jpg" - _file.seek(0) - - return _get_color(_file) - - def extract_color_from_path(file_path): - """Handle call for local file based image.""" - if not hass.config.is_allowed_path(file_path): - _LOGGER.error( - ( - "File path '%s' is not allowed, please add to" - " 'allowlist_external_dirs'" - ), - file_path, - ) - return None - - _LOGGER.debug("Getting predominant RGB from file path '%s'", file_path) - - _file = _get_file(file_path) - return _get_color(_file) - + async_setup_services(hass) return True diff --git a/homeassistant/components/color_extractor/services.py b/homeassistant/components/color_extractor/services.py new file mode 100644 index 00000000000..d5d90bca308 --- /dev/null +++ b/homeassistant/components/color_extractor/services.py @@ -0,0 +1,156 @@ +"""Module for color_extractor (RGB extraction from images) component.""" + +import asyncio +import io +import logging + +import aiohttp +from colorthief import ColorThief +from PIL import UnidentifiedImageError +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + LIGHT_TURN_ON_SCHEMA, +) +from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON + +_LOGGER = logging.getLogger(__name__) + +# Extend the existing light.turn_on service schema +SERVICE_SCHEMA = vol.All( + cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), + cv.make_entity_service_schema( + { + **LIGHT_TURN_ON_SCHEMA, + vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile, + vol.Exclusive(ATTR_URL, "color_extractor"): cv.url, + } + ), +) + + +def _get_file(file_path: str) -> str: + """Get a PIL acceptable input file reference. + + Allows us to mock patch during testing to make BytesIO stream. + """ + return file_path + + +def _get_color(file_handler: io.BytesIO | str) -> tuple[int, int, int]: + """Given an image file, extract the predominant color from it.""" + color_thief = ColorThief(file_handler) + + # get_color returns a SINGLE RGB value for the given image + color = color_thief.get_color(quality=1) + + _LOGGER.debug("Extracted RGB color %s from image", color) + + return color + + +async def _async_extract_color_from_url( + hass: HomeAssistant, url: str +) -> tuple[int, int, int] | None: + """Handle call for URL based image.""" + if not hass.config.is_allowed_external_url(url): + _LOGGER.error( + ( + "External URL '%s' is not allowed, please add to" + " 'allowlist_external_urls'" + ), + url, + ) + return None + + _LOGGER.debug("Getting predominant RGB from image URL '%s'", url) + + # Download the image into a buffer for ColorThief to check against + try: + session = aiohttp_client.async_get_clientsession(hass) + + async with asyncio.timeout(10): + response = await session.get(url) + + except (TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err) + return None + + content = await response.content.read() + + with io.BytesIO(content) as _file: + _file.name = "color_extractor.jpg" + _file.seek(0) + + return _get_color(_file) + + +def _extract_color_from_path( + hass: HomeAssistant, file_path: str +) -> tuple[int, int, int] | None: + """Handle call for local file based image.""" + if not hass.config.is_allowed_path(file_path): + _LOGGER.error( + "File path '%s' is not allowed, please add to 'allowlist_external_dirs'", + file_path, + ) + return None + + _LOGGER.debug("Getting predominant RGB from file path '%s'", file_path) + + _file = _get_file(file_path) + return _get_color(_file) + + +async def async_handle_service(service_call: ServiceCall) -> None: + """Decide which color_extractor method to call based on service.""" + service_data = dict(service_call.data) + + try: + if ATTR_URL in service_data: + image_type = "URL" + image_reference = service_data.pop(ATTR_URL) + color = await _async_extract_color_from_url( + service_call.hass, image_reference + ) + + elif ATTR_PATH in service_data: + image_type = "file path" + image_reference = service_data.pop(ATTR_PATH) + color = await service_call.hass.async_add_executor_job( + _extract_color_from_path, service_call.hass, image_reference + ) + + except UnidentifiedImageError as ex: + _LOGGER.error( + "Bad image from %s '%s' provided, are you sure it's an image? %s", + image_type, + image_reference, + ex, + ) + return + + if color: + service_data[ATTR_RGB_COLOR] = color + + await service_call.hass.services.async_call( + LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the services.""" + + hass.services.async_register( + DOMAIN, + SERVICE_TURN_ON, + async_handle_service, + schema=SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/compit/manifest.json b/homeassistant/components/compit/manifest.json index b686c406ad1..8c32dcc8e45 100644 --- a/homeassistant/components/compit/manifest.json +++ b/homeassistant/components/compit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["compit"], "quality_scale": "bronze", - "requirements": ["compit-inext-api==0.3.1"] + "requirements": ["compit-inext-api==0.3.4"] } diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index f97bb7ddd8a..3e0a9c1df5f 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -65,8 +65,10 @@ def websocket_create_area( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} if "labels" in data: # Convert labels to a set @@ -133,8 +135,10 @@ def websocket_update_area( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} if "labels" in data: # Convert labels to a set diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index d619b585230..a1ce5645d6b 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -227,8 +227,10 @@ def websocket_update_entity( changes[key] = msg[key] if "aliases" in msg: - # Convert aliases to a set - changes["aliases"] = set(msg["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())} if "labels" in msg: # Convert labels to a set diff --git a/homeassistant/components/config/floor_registry.py b/homeassistant/components/config/floor_registry.py index f33051dfc7f..a4545193979 100644 --- a/homeassistant/components/config/floor_registry.py +++ b/homeassistant/components/config/floor_registry.py @@ -61,8 +61,10 @@ def websocket_create_floor( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} try: entry = registry.async_create(**data) @@ -117,8 +119,10 @@ def websocket_update_floor( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} try: entry = registry.async_update(**data) diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index e7123803ede..685ae8bf05a 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -1,9 +1,10 @@ { "domain": "control4", "name": "Control4", - "codeowners": ["@lawtancool"], + "codeowners": ["@lawtancool", "@davidrecordon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/control4", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyControl4"], "requirements": ["pyControl4==1.5.0"], diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 18a3e943bbc..d2dd940a443 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import CONF_SWING_SUPPORT, DOMAIN +from .const import CONF_SEND_WAKEUP_PROMPT, CONF_SWING_SUPPORT, DOMAIN from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] @@ -17,10 +17,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) - """Set up Coolmaster from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] + send_wakeup_prompt = entry.data.get(CONF_SEND_WAKEUP_PROMPT, False) if not entry.data.get(CONF_SWING_SUPPORT): coolmaster = CoolMasterNet( host, port, + send_initial_line_feed=send_wakeup_prompt, ) else: # Swing support adds an additional request per unit. The requests are @@ -29,6 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) - coolmaster = CoolMasterNet( host, port, + send_initial_line_feed=send_wakeup_prompt, read_timeout=5, swing_support=True, ) diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py index 19832eaef0a..d9c16dcb7cf 100644 --- a/homeassistant/components/coolmaster/config_flow.py +++ b/homeassistant/components/coolmaster/config_flow.py @@ -12,7 +12,13 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback -from .const import CONF_SUPPORTED_MODES, CONF_SWING_SUPPORT, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_SEND_WAKEUP_PROMPT, + CONF_SUPPORTED_MODES, + CONF_SWING_SUPPORT, + DEFAULT_PORT, + DOMAIN, +) AVAILABLE_MODES = [ HVACMode.OFF.value, @@ -25,17 +31,15 @@ AVAILABLE_MODES = [ MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES} -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - **MODES_SCHEMA, - vol.Required(CONF_SWING_SUPPORT, default=False): bool, - } -) +DATA_SCHEMA = { + vol.Required(CONF_HOST): str, + **MODES_SCHEMA, + vol.Required(CONF_SWING_SUPPORT, default=False): bool, +} -async def _validate_connection(host: str) -> bool: - cool = CoolMasterNet(host, DEFAULT_PORT) +async def _validate_connection(host: str, send_wakeup_prompt: bool) -> bool: + cool = CoolMasterNet(host, DEFAULT_PORT, send_initial_line_feed=send_wakeup_prompt) units = await cool.status() return bool(units) @@ -45,6 +49,14 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def _get_data_schema(self) -> vol.Schema: + schema_dict = DATA_SCHEMA.copy() + + if self.show_advanced_options: + schema_dict[vol.Required(CONF_SEND_WAKEUP_PROMPT, default=False)] = bool + + return vol.Schema(schema_dict) + @callback def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult: supported_modes = [ @@ -57,6 +69,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PORT: DEFAULT_PORT, CONF_SUPPORTED_MODES: supported_modes, CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT], + CONF_SEND_WAKEUP_PROMPT: data.get(CONF_SEND_WAKEUP_PROMPT, False), }, ) @@ -64,15 +77,19 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" + data_schema = self._get_data_schema() + if user_input is None: - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + return self.async_show_form(step_id="user", data_schema=data_schema) errors = {} host = user_input[CONF_HOST] try: - result = await _validate_connection(host) + result = await _validate_connection( + host, user_input.get(CONF_SEND_WAKEUP_PROMPT, False) + ) if not result: errors["base"] = "no_units" except OSError: @@ -80,7 +97,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): if errors: return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=data_schema, errors=errors ) return self._async_get_entry(user_input) diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py index 567b7e9f13b..ce6fe45adc4 100644 --- a/homeassistant/components/coolmaster/const.py +++ b/homeassistant/components/coolmaster/const.py @@ -6,5 +6,6 @@ DEFAULT_PORT = 10102 CONF_SUPPORTED_MODES = "supported_modes" CONF_SWING_SUPPORT = "swing_support" +CONF_SEND_WAKEUP_PROMPT = "send_wakeup_prompt" MAX_RETRIES = 3 BACKOFF_BASE_DELAY = 2 diff --git a/homeassistant/components/coolmaster/manifest.json b/homeassistant/components/coolmaster/manifest.json index 8775d7f72b8..f68aea9fb29 100644 --- a/homeassistant/components/coolmaster/manifest.json +++ b/homeassistant/components/coolmaster/manifest.json @@ -4,7 +4,8 @@ "codeowners": ["@OnFreund"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coolmaster", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pycoolmasternet_async"], - "requirements": ["pycoolmasternet-async==0.2.2"] + "requirements": ["pycoolmasternet-async==0.2.4"] } diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 77c5765ab78..3697f50efb9 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -14,10 +14,12 @@ "heat_cool": "Support automatic heat/cool mode", "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", + "send_wakeup_prompt": "Send wakeup prompt", "swing_support": "Control swing mode" }, "data_description": { - "host": "The hostname or IP address of your CoolMasterNet device." + "host": "The hostname or IP address of your CoolMasterNet device.", + "send_wakeup_prompt": "Send the coolmaster unit an empty commaand before issuing any actual command. This is required for serial models." }, "description": "Set up your CoolMasterNet connection details." } diff --git a/homeassistant/components/cync/manifest.json b/homeassistant/components/cync/manifest.json index 16f77debdfa..d47cc766009 100644 --- a/homeassistant/components/cync/manifest.json +++ b/homeassistant/components/cync/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["pycync==0.4.3"] + "requirements": ["pycync==0.5.0"] } diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 54f974e60a5..a34fefa6ce6 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fredrike"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/daikin", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], "requirements": ["pydaikin==2.17.1"], diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index 798a314e307..3d0e4cf8e8a 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/datadog", + "integration_type": "service", "iot_class": "local_push", "loggers": ["datadog"], "quality_scale": "legacy", diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index e93834ec6ed..6e418775407 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ol-iver", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", + "integration_type": "device", "iot_class": "local_push", "loggers": ["denonavr"], "requirements": ["denonavr==1.2.0"], diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index f9894f6658e..dde1ee7bfe0 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Protocol +from typing import Any, Protocol import voluptuous as vol @@ -11,18 +11,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( Condition, + ConditionChecker, ConditionCheckerType, ConditionConfig, - trace_condition_function, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DeviceAutomationType, async_get_device_automation_platform from .helpers import async_validate_device_automation_config -if TYPE_CHECKING: - from homeassistant.helpers import condition - class DeviceAutomationConditionProtocol(Protocol): """Define the format of device_condition modules. @@ -90,15 +87,21 @@ class DeviceCondition(Condition): assert config.options is not None self._config = config.options - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Test a device condition.""" platform = await async_get_device_automation_platform( self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) - return trace_condition_function( - platform.async_condition_from_config(self._hass, self._config) + platform_checker = platform.async_condition_from_config( + self._hass, self._config ) + def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool: + result = platform_checker(self._hass, variables) + return result is not False + + return checker + CONDITIONS: dict[str, type[Condition]] = { "_device": DeviceCondition, diff --git a/homeassistant/components/device_tracker/icons.json b/homeassistant/components/device_tracker/icons.json index 4e5b82576cf..69bd22ae4b8 100644 --- a/homeassistant/components/device_tracker/icons.json +++ b/homeassistant/components/device_tracker/icons.json @@ -11,5 +11,13 @@ "see": { "service": "mdi:account-eye" } + }, + "triggers": { + "entered_home": { + "trigger": "mdi:account-arrow-left" + }, + "left_home": { + "trigger": "mdi:account-arrow-right" + } } } diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 08345e3c510..646ba98554e 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "condition_type": { "is_home": "{entity_name} is home", @@ -44,6 +48,15 @@ } } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "see": { "description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.", @@ -80,5 +93,27 @@ "name": "See" } }, - "title": "Device tracker" + "title": "Device tracker", + "triggers": { + "entered_home": { + "description": "Triggers when one or more device trackers enter home.", + "fields": { + "behavior": { + "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", + "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" + } + }, + "name": "Entered home" + }, + "left_home": { + "description": "Triggers when one or more device trackers leave home.", + "fields": { + "behavior": { + "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", + "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" + } + }, + "name": "Left home" + } + } } diff --git a/homeassistant/components/device_tracker/trigger.py b/homeassistant/components/device_tracker/trigger.py new file mode 100644 index 00000000000..7f1d2bd068e --- /dev/null +++ b/homeassistant/components/device_tracker/trigger.py @@ -0,0 +1,21 @@ +"""Provides triggers for device_trackers.""" + +from homeassistant.const import STATE_HOME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import ( + Trigger, + make_entity_origin_state_trigger, + make_entity_target_state_trigger, +) + +from .const import DOMAIN + +TRIGGERS: dict[str, type[Trigger]] = { + "entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME), + "left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for device trackers.""" + return TRIGGERS diff --git a/homeassistant/components/device_tracker/triggers.yaml b/homeassistant/components/device_tracker/triggers.yaml new file mode 100644 index 00000000000..e75f072ba8c --- /dev/null +++ b/homeassistant/components/device_tracker/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: device_tracker + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +entered_home: *trigger_common +left_home: *trigger_common diff --git a/homeassistant/components/dexcom/manifest.json b/homeassistant/components/dexcom/manifest.json index 404ca7cb59e..0244fc71d65 100644 --- a/homeassistant/components/dexcom/manifest.json +++ b/homeassistant/components/dexcom/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gagebenne"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dexcom", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pydexcom"], "requirements": ["pydexcom==0.2.3"] diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 2f5683dc880..a655ff719e4 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/directv", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["directv"], "requirements": ["directv==0.4.0"], diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index bcb217e1d01..505df119d37 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -9,7 +9,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 8eee761a70c..9e6216dffdf 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.46.0"], + "requirements": ["async-upnp-client==0.46.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 98d0b21db87..f014cbdbe88 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", + "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.6.0"] + "requirements": ["aiodns==3.6.1"] } diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 9debb865ab2..423dd2f7bd9 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http", "repairs"], "documentation": "https://www.home-assistant.io/integrations/doorbird", + "integration_type": "device", "iot_class": "local_push", "loggers": ["doorbirdpy"], "requirements": ["DoorBirdPy==3.0.11"], diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json index ed34767d6e0..c23bc5a99e3 100644 --- a/homeassistant/components/drop_connect/manifest.json +++ b/homeassistant/components/drop_connect/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/drop_connect", + "integration_type": "hub", "iot_class": "local_push", "mqtt": ["drop_connect/discovery/#"], "requirements": ["dropmqttapi==1.0.3"] diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json index 80ded2d23a7..73e6ab4d499 100644 --- a/homeassistant/components/droplet/manifest.json +++ b/homeassistant/components/droplet/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@sarahseidman"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/droplet", + "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", "requirements": ["pydroplet==2.3.4"], diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json index ad64fdd5cc4..cbce6db82a1 100644 --- a/homeassistant/components/duke_energy/manifest.json +++ b/homeassistant/components/duke_energy/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/duke_energy", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["aiodukeenergy==0.3.0"] } diff --git a/homeassistant/components/dunehd/manifest.json b/homeassistant/components/dunehd/manifest.json index b5528e0f565..4e9f3f721da 100644 --- a/homeassistant/components/dunehd/manifest.json +++ b/homeassistant/components/dunehd/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dunehd", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pdunehd"], "requirements": ["pdunehd==1.3.2"] diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 7a79902eae3..a647f39beca 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@cereal2nd"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "requirements": ["pyDuotecno==2024.10.1"], diff --git a/homeassistant/components/eafm/manifest.json b/homeassistant/components/eafm/manifest.json index 1a2d4de6174..ed39b2cd20b 100644 --- a/homeassistant/components/eafm/manifest.json +++ b/homeassistant/components/eafm/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Jc2k"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/eafm", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioeafm"], "requirements": ["aioeafm==0.1.2"] diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index dc8c973577c..12a5314ad2a 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["EB", "ecobee*"] }, + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyecobee"], "requirements": ["python-ecobee-api==0.3.2"], diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json index cca44c5b2a9..063474448d9 100644 --- a/homeassistant/components/ecoforest/manifest.json +++ b/homeassistant/components/ecoforest/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@pjanuario"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecoforest", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyecoforest"], "requirements": ["pyecoforest==0.4.0"] diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index bc7505740d7..c19a6a6f414 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@w1ll1am23"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/econet", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], "requirements": ["pyeconet==0.1.28"] diff --git a/homeassistant/components/ekeybionyx/event.py b/homeassistant/components/ekeybionyx/event.py index b847637465b..cbf8d553009 100644 --- a/homeassistant/components/ekeybionyx/event.py +++ b/homeassistant/components/ekeybionyx/event.py @@ -1,5 +1,7 @@ """Event platform for ekey bionyx integration.""" +from http import HTTPStatus + from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response @@ -52,8 +54,18 @@ class EkeyEvent(EventEntity): async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: - if (await request.json())["auth"] == self._auth: - self._async_handle_event() + try: + payload = await request.json() + except ValueError: + return Response(status=HTTPStatus.BAD_REQUEST) + auth = payload.get("auth") + + if auth is None: + return Response(status=HTTPStatus.BAD_REQUEST) + if auth != self._auth: + return Response(status=HTTPStatus.UNAUTHORIZED) + + self._async_handle_event() return None webhook_register( diff --git a/homeassistant/components/ekeybionyx/manifest.json b/homeassistant/components/ekeybionyx/manifest.json index a53dc13b993..4fa07e197ff 100644 --- a/homeassistant/components/ekeybionyx/manifest.json +++ b/homeassistant/components/ekeybionyx/manifest.json @@ -5,7 +5,8 @@ "config_flow": true, "dependencies": ["application_credentials", "http"], "documentation": "https://www.home-assistant.io/integrations/ekeybionyx", + "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["ekey-bionyxpy==1.0.0"] + "requirements": ["ekey-bionyxpy==1.0.1"] } diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json index f21f02b8cfe..8e9a4cfe4f9 100644 --- a/homeassistant/components/electrasmart/manifest.json +++ b/homeassistant/components/electrasmart/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@jafar-atili"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/electrasmart", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["pyElectra==1.2.4"] } diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index c36c5aa9a68..1cc39278f8e 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -13,6 +13,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/elkm1", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["elkm1_lib"], "requirements": ["elkm1-lib==2.2.13"] diff --git a/homeassistant/components/elvia/manifest.json b/homeassistant/components/elvia/manifest.json index abb4f846f00..82ea18fac3f 100644 --- a/homeassistant/components/elvia/manifest.json +++ b/homeassistant/components/elvia/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/elvia", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["elvia==0.1.0"] } diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index d21da453976..f69d6fa183b 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@borpin", "@alexandrecuer"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emoncms", + "integration_type": "service", "iot_class": "local_polling", "requirements": ["pyemoncms==0.1.3"] } diff --git a/homeassistant/components/emonitor/manifest.json b/homeassistant/components/emonitor/manifest.json index 8c6daa2c077..f079f564339 100644 --- a/homeassistant/components/emonitor/manifest.json +++ b/homeassistant/components/emonitor/manifest.json @@ -13,6 +13,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/emonitor", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["aioemonitor"], "requirements": ["aioemonitor==1.0.5"] diff --git a/homeassistant/components/energyid/strings.json b/homeassistant/components/energyid/strings.json index 4c0f4a23501..e735285cd58 100644 --- a/homeassistant/components/energyid/strings.json +++ b/homeassistant/components/energyid/strings.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "This device is already configured.", - "reauth_successful": "Reauthentication successful." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "add_sensor_mapping_hint": "You can now add mappings from any sensor in Home Assistant to {integration_name} using the '+ add sensor mapping' button." diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json index bd79d591f6b..b7eba277b77 100644 --- a/homeassistant/components/enocean/manifest.json +++ b/homeassistant/components/enocean/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enocean", + "integration_type": "device", "iot_class": "local_push", "loggers": ["enocean"], "requirements": ["enocean==0.50"], diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 60a44be9a9a..bebbbe004e9 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bdraco", "@cgarwood", "@catsmanac"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json index 5e25eb4b4a7..f67867fe07b 100644 --- a/homeassistant/components/entur_public_transport/manifest.json +++ b/homeassistant/components/entur_public_transport/manifest.json @@ -1,8 +1,9 @@ { "domain": "entur_public_transport", "name": "Entur", - "codeowners": ["@hfurubotten"], + "codeowners": ["@hfurubotten", "@SanderBlom"], "documentation": "https://www.home-assistant.io/integrations/entur_public_transport", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["enturclient"], "quality_scale": "legacy", diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index ce1b6b49d9f..3cbf5b5b2fd 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gwww", "@michaeldavie"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/environment_canada", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["env_canada"], "requirements": ["env-canada==0.12.1"] diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json index 1d2ea0e849d..026fdf471f3 100644 --- a/homeassistant/components/epson/manifest.json +++ b/homeassistant/components/epson/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@pszafer"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/epson", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["epson_projector"], "requirements": ["epson-projector==0.6.0"] diff --git a/homeassistant/components/escea/manifest.json b/homeassistant/components/escea/manifest.json index 35e0cec183f..d2adc90d6dc 100644 --- a/homeassistant/components/escea/manifest.json +++ b/homeassistant/components/escea/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["Escea"] }, + "integration_type": "device", "iot_class": "local_push", "requirements": ["pescea==1.0.12"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9fa0291a020..00a890dcd24 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==43.0.0", + "aioesphomeapi==43.3.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.4.0" ], diff --git a/homeassistant/components/evil_genius_labs/manifest.json b/homeassistant/components/evil_genius_labs/manifest.json index 9f096961f2f..42e84c4cd7b 100644 --- a/homeassistant/components/evil_genius_labs/manifest.json +++ b/homeassistant/components/evil_genius_labs/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/evil_genius_labs", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["pyevilgenius==2.0.0"] } diff --git a/homeassistant/components/faa_delays/manifest.json b/homeassistant/components/faa_delays/manifest.json index 07c2cfea771..d048f110638 100644 --- a/homeassistant/components/faa_delays/manifest.json +++ b/homeassistant/components/faa_delays/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ntilley905"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/faa_delays", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["faadelays"], "requirements": ["faadelays==2023.9.1"] diff --git a/homeassistant/components/fan/trigger.py b/homeassistant/components/fan/trigger.py index 9135f22deb0..e36970afdfc 100644 --- a/homeassistant/components/fan/trigger.py +++ b/homeassistant/components/fan/trigger.py @@ -2,13 +2,13 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger from . import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { - "turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF), - "turned_on": make_entity_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/fing/manifest.json b/homeassistant/components/fing/manifest.json index a517b12d232..af2fb867039 100644 --- a/homeassistant/components/fing/manifest.json +++ b/homeassistant/components/fing/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Lorenzo-Gasparini"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fing", + "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", "requirements": ["fing_agent_api==1.0.3"] diff --git a/homeassistant/components/firefly_iii/manifest.json b/homeassistant/components/firefly_iii/manifest.json index 7ea5cbe7a9a..2c04a022f08 100644 --- a/homeassistant/components/firefly_iii/manifest.json +++ b/homeassistant/components/firefly_iii/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@erwindouna"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/firefly_iii", + "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", "requirements": ["pyfirefly==0.1.8"] diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json index 163c9f315c0..d3b11743ecf 100644 --- a/homeassistant/components/firefly_iii/strings.json +++ b/homeassistant/components/firefly_iii/strings.json @@ -42,11 +42,11 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "api_key": "The API key for authenticating with Firefly", + "api_key": "The API key for authenticating with Firefly III", "url": "[%key:common::config_flow::data::url%]", - "verify_ssl": "Verify the SSL certificate of the Firefly instance" + "verify_ssl": "Verify the SSL certificate of the Firefly III instance" }, - "description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." + "description": "You can create an API key in the Firefly III UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." } } }, @@ -84,13 +84,13 @@ }, "exceptions": { "cannot_connect": { - "message": "An error occurred while trying to connect to the Firefly instance: {error}" + "message": "An error occurred while trying to connect to the Firefly III instance: {error}" }, "invalid_auth": { "message": "An error occurred while trying to authenticate: {error}" }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Firefly instance: {error}" + "message": "A timeout occurred while trying to connect to the Firefly III instance: {error}" } } } diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index 945ef141887..56966285edb 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@cyberjunky"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fireservicerota", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyfireservicerota"], "requirements": ["pyfireservicerota==0.0.46"] diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 443f4c0b2fc..2a11b393844 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials", "http"], "documentation": "https://www.home-assistant.io/integrations/fitbit", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["fitbit", "fitbit_web_api"], "requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"] diff --git a/homeassistant/components/fivem/manifest.json b/homeassistant/components/fivem/manifest.json index a2a87f261aa..d9b972694b1 100644 --- a/homeassistant/components/fivem/manifest.json +++ b/homeassistant/components/fivem/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Sander0542"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fivem", + "integration_type": "service", "iot_class": "local_polling", "requirements": ["fivem-api==0.1.2"] } diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json index 5aac42b5ed3..d321dcdccc4 100644 --- a/homeassistant/components/fjaraskupan/manifest.json +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -12,6 +12,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["bleak", "fjaraskupan"], "requirements": ["fjaraskupan==2.3.3"] diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json index cdd03770bab..b1324f4c825 100644 --- a/homeassistant/components/flipr/manifest.json +++ b/homeassistant/components/flipr/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@cnico"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flipr", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["flipr_api"], "requirements": ["flipr-api==1.6.1"] diff --git a/homeassistant/components/flo/manifest.json b/homeassistant/components/flo/manifest.json index 10b5faaf821..f8d30076314 100644 --- a/homeassistant/components/flo/manifest.json +++ b/homeassistant/components/flo/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dmulcahey"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flo", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioflo"], "requirements": ["aioflo==2021.11.0"] diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index 953d9791f2f..0c3997935e1 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -9,6 +9,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/flume", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyflume"], "requirements": ["PyFlume==0.6.5"] diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index c834e0f7e1e..9d68e42f9e5 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Foscam-wangzhengyu"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["libpyfoscamcgi"], "requirements": ["libpyfoscamcgi==0.0.9"] diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 0cfe37c7a31..9f28272caaf 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/freebox", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["freebox_api"], "requirements": ["freebox-api==1.2.2"], diff --git a/homeassistant/components/freedompro/manifest.json b/homeassistant/components/freedompro/manifest.json index b4e183ac5dd..a56dcbdbccb 100644 --- a/homeassistant/components/freedompro/manifest.json +++ b/homeassistant/components/freedompro/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@stefano055415"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/freedompro", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyfreedompro"], "requirements": ["pyfreedompro==1.1.0"] diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index a95af62da6c..256c1258c38 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError -from pyfritzhome.devicetypes import FritzhomeTemplate +from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError from homeassistant.config_entries import ConfigEntry @@ -27,6 +27,7 @@ class FritzboxCoordinatorData: devices: dict[str, FritzhomeDevice] templates: dict[str, FritzhomeTemplate] + triggers: dict[str, FritzhomeTrigger] supported_color_properties: dict[str, tuple[dict, list]] @@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat configuration_url: str fritz: Fritzhome has_templates: bool + has_triggers: bool def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None: """Initialize the Fritzbox Smarthome device coordinator.""" @@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.new_devices: set[str] = set() self.new_templates: set[str] = set() + self.new_triggers: set[str] = set() - self.data = FritzboxCoordinatorData({}, {}, {}) + self.data = FritzboxCoordinatorData({}, {}, {}, {}) async def async_setup(self) -> None: """Set up the coordinator.""" @@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat ) LOGGER.debug("enable smarthome templates: %s", self.has_templates) + self.has_triggers = await self.hass.async_add_executor_job( + self.fritz.has_triggers + ) + LOGGER.debug("enable smarthome triggers: %s", self.has_triggers) + self.configuration_url = self.fritz.get_prefixed_host() await self.async_config_entry_first_refresh() @@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat available_main_ains = [ ain - for ain, dev in data.devices.items() | data.templates.items() + for ain, dev in (data.devices | data.templates | data.triggers).items() if dev.device_and_unit_id[1] is None ] device_reg = dr.async_get(self.hass) @@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.fritz.update_devices(ignore_removed=False) if self.has_templates: self.fritz.update_templates(ignore_removed=False) + if self.has_triggers: + self.fritz.update_triggers(ignore_removed=False) + except RequestConnectionError as ex: raise UpdateFailed from ex except HTTPError: @@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.fritz.update_devices(ignore_removed=False) if self.has_templates: self.fritz.update_templates(ignore_removed=False) + if self.has_triggers: + self.fritz.update_triggers(ignore_removed=False) devices = self.fritz.get_devices() device_data = {} @@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for template in templates: template_data[template.ain] = template + trigger_data = {} + if self.has_triggers: + triggers = self.fritz.get_triggers() + for trigger in triggers: + trigger_data[trigger.ain] = trigger + self.new_devices = device_data.keys() - self.data.devices.keys() self.new_templates = template_data.keys() - self.data.templates.keys() + self.new_triggers = trigger_data.keys() - self.data.triggers.keys() return FritzboxCoordinatorData( devices=device_data, templates=template_data, + triggers=trigger_data, supported_color_properties=supported_color_properties, ) @@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat if ( self.data.devices.keys() - new_data.devices.keys() or self.data.templates.keys() - new_data.templates.keys() + or self.data.triggers.keys() - new_data.triggers.keys() ): self.cleanup_removed_devices(new_data) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index fae574883a3..6f77c6b7cfe 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.17"], + "requirements": ["pyfritzhome==0.6.18"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 88f7f127f1a..9ddc48b55d3 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -4,14 +4,17 @@ from __future__ import annotations from typing import Any +from pyfritzhome.devicetypes import FritzhomeTrigger + from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import FritzboxConfigEntry -from .entity import FritzBoxDeviceEntity +from .entity import FritzBoxDeviceEntity, FritzBoxEntity # Coordinator handles data updates, so we can allow unlimited parallel updates PARALLEL_UPDATES = 0 @@ -26,21 +29,27 @@ async def async_setup_entry( coordinator = entry.runtime_data @callback - def _add_entities(devices: set[str] | None = None) -> None: - """Add devices.""" + def _add_entities( + devices: set[str] | None = None, triggers: set[str] | None = None + ) -> None: + """Add devices and triggers.""" if devices is None: devices = coordinator.new_devices - if not devices: + if triggers is None: + triggers = coordinator.new_triggers + if not devices and not triggers: return - async_add_entities( + entities = [ FritzboxSwitch(coordinator, ain) for ain in devices if coordinator.data.devices[ain].has_switch - ) + ] + [FritzboxTrigger(coordinator, ain) for ain in triggers] + + async_add_entities(entities) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices)) + _add_entities(set(coordinator.data.devices), set(coordinator.data.triggers)) class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): @@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): translation_domain=DOMAIN, translation_key="manual_switching_disabled", ) + + +class FritzboxTrigger(FritzBoxEntity, SwitchEntity): + """The switch class for FRITZ!SmartHome triggers.""" + + @property + def data(self) -> FritzhomeTrigger: + """Return the trigger data entity.""" + return self.coordinator.data.triggers[self.ain] + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return DeviceInfo( + name=self.data.name, + identifiers={(DOMAIN, self.ain)}, + configuration_url=self.coordinator.configuration_url, + manufacturer="FRITZ!", + model="SmartHome Routine", + ) + + @property + def is_on(self) -> bool: + """Return true if the trigger is active.""" + return self.data.active # type: ignore [no-any-return] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Activate the trigger.""" + await self.hass.async_add_executor_job( + self.coordinator.fritz.set_trigger_active, self.ain + ) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Deactivate the trigger.""" + await self.hass.async_add_executor_job( + self.coordinator.fritz.set_trigger_inactive, self.ain + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 9cc928e6f88..baa684786bf 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -4,7 +4,12 @@ "codeowners": ["@wlcrs"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["afsapi==0.2.7"], - "ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }] + "ssdp": [ + { + "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" + } + ] } diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index c8fed9b45c9..24cfaad3049 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@crevetor"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["ayla-iot-unofficial==1.4.7"] } diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index 1fbbb6656a2..9322d42e148 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -10,6 +10,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/fully_kiosk", + "integration_type": "device", "iot_class": "local_polling", "mqtt": ["fully/deviceInfo/+"], "quality_scale": "bronze", diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index 8c386e85418..146608c3901 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -17,7 +17,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( NumberEntityDescription( key="timeToScreensaverV2", translation_key="screensaver_time", - native_max_value=9999, + native_max_value=86400, native_step=1, native_min_value=0, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -34,7 +34,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( NumberEntityDescription( key="timeToScreenOffV2", translation_key="screen_off_time", - native_max_value=9999, + native_max_value=86400, native_step=1, native_min_value=0, native_unit_of_measurement=UnitOfTime.SECONDS, diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index e74deac25c4..1377cb7e47a 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@klaasnicolaas"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["odp-amsterdam==6.1.2"] } diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 0cc63c83840..b3d0bd8257a 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -12,6 +12,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], "requirements": ["gardena-bluetooth==1.6.0"] diff --git a/homeassistant/components/generic/__init__.py b/homeassistant/components/generic/__init__.py index 3de664dd734..5fdb27ce516 100644 --- a/homeassistant/components/generic/__init__.py +++ b/homeassistant/components/generic/__init__.py @@ -2,15 +2,23 @@ from __future__ import annotations +import logging from typing import Any +from homeassistant.components.stream import ( + CONF_RTSP_TRANSPORT, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_AUTHENTICATION, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED + DOMAIN = "generic" PLATFORMS = [Platform.CAMERA] +_LOGGER = logging.getLogger(__name__) async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -47,3 +55,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 1: + # Migrate to advanced section + new_options = {**entry.options} + advanced = new_options[SECTION_ADVANCED] = { + CONF_FRAMERATE: new_options.pop(CONF_FRAMERATE), + CONF_VERIFY_SSL: new_options.pop(CONF_VERIFY_SSL), + } + + # migrate optional fields + for key in ( + CONF_RTSP_TRANSPORT, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + CONF_AUTHENTICATION, + CONF_LIMIT_REFETCH_TO_URL_CHANGE, + ): + if key in new_options: + advanced[key] = new_options.pop(key) + + hass.config_entries.async_update_entry(entry, options=new_options, version=2) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 6821300fadf..530d9a0bb9a 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -41,6 +41,7 @@ from .const import ( CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, GET_IMAGE_TIMEOUT, + SECTION_ADVANCED, ) _LOGGER = logging.getLogger(__name__) @@ -62,9 +63,11 @@ def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None: """Generate httpx.Auth object from credentials.""" username: str | None = device_info.get(CONF_USERNAME) password: str | None = device_info.get(CONF_PASSWORD) - authentication = device_info.get(CONF_AUTHENTICATION) if username and password: - if authentication == HTTP_DIGEST_AUTHENTICATION: + if ( + device_info[SECTION_ADVANCED].get(CONF_AUTHENTICATION) + == HTTP_DIGEST_AUTHENTICATION + ): return httpx.DigestAuth(username=username, password=password) return httpx.BasicAuth(username=username, password=password) return None @@ -99,14 +102,16 @@ class GenericCamera(Camera): if self._stream_source: self._stream_source = Template(self._stream_source, hass) self._attr_supported_features = CameraEntityFeature.STREAM - self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False) - self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE] + self._limit_refetch = device_info[SECTION_ADVANCED].get( + CONF_LIMIT_REFETCH_TO_URL_CHANGE, False + ) + self._attr_frame_interval = 1 / device_info[SECTION_ADVANCED][CONF_FRAMERATE] self.content_type = device_info[CONF_CONTENT_TYPE] - self.verify_ssl = device_info[CONF_VERIFY_SSL] - if device_info.get(CONF_RTSP_TRANSPORT): - self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT] + self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL] + if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT): + self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport self._auth = generate_auth(device_info) - if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + if device_info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True self._last_url = None diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index aacf017eedd..98c725c4b9c 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -50,10 +50,18 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.network import get_url +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -67,16 +75,20 @@ from .const import ( DEFAULT_NAME, DOMAIN, GET_IMAGE_TIMEOUT, + SECTION_ADVANCED, ) _LOGGER = logging.getLogger(__name__) DEFAULT_DATA = { CONF_NAME: DEFAULT_NAME, - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, - CONF_FRAMERATE: 2, - CONF_VERIFY_SSL: True, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, + CONF_FRAMERATE: 2, + CONF_VERIFY_SSL: True, + CONF_RTSP_TRANSPORT: "tcp", + }, } SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} @@ -93,58 +105,47 @@ class InvalidStreamException(HomeAssistantError): def build_schema( - user_input: Mapping[str, Any], is_options_flow: bool = False, show_advanced_options: bool = False, ) -> vol.Schema: """Create schema for camera config setup.""" + rtsp_options = [ + SelectOptionDict( + value=value, + label=name, + ) + for value, name in RTSP_TRANSPORTS.items() + ] + + advanced_section = { + vol.Required(CONF_FRAMERATE): vol.All( + vol.Range(min=0, min_included=False), cv.positive_float + ), + vol.Required(CONF_VERIFY_SSL): bool, + vol.Optional(CONF_RTSP_TRANSPORT): SelectSelector( + SelectSelectorConfig( + options=rtsp_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_AUTHENTICATION): vol.In( + [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] + ), + } spec = { - vol.Optional( - CONF_STILL_IMAGE_URL, - description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")}, - ): str, - vol.Optional( - CONF_STREAM_SOURCE, - description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")}, - ): str, - vol.Optional( - CONF_RTSP_TRANSPORT, - description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)}, - ): vol.In(RTSP_TRANSPORTS), - vol.Optional( - CONF_AUTHENTICATION, - description={"suggested_value": user_input.get(CONF_AUTHENTICATION)}, - ): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), - vol.Optional( - CONF_USERNAME, - description={"suggested_value": user_input.get(CONF_USERNAME, "")}, - ): str, - vol.Optional( - CONF_PASSWORD, - description={"suggested_value": user_input.get(CONF_PASSWORD, "")}, - ): str, - vol.Required( - CONF_FRAMERATE, - description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)}, - ): vol.All(vol.Range(min=0, min_included=False), cv.positive_float), - vol.Required( - CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True) - ): bool, + vol.Optional(CONF_STREAM_SOURCE): str, + vol.Optional(CONF_STILL_IMAGE_URL): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(SECTION_ADVANCED): section( + vol.Schema(advanced_section), {"collapsed": True} + ), } if is_options_flow: - spec[ - vol.Required( - CONF_LIMIT_REFETCH_TO_URL_CHANGE, - default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False), - ) - ] = bool + advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool if show_advanced_options: - spec[ - vol.Required( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, - default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), - ) - ] = bool + advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool + return vol.Schema(spec) @@ -186,7 +187,7 @@ async def async_test_still( return {CONF_STILL_IMAGE_URL: "malformed_url"}, None if not yarl_url.is_absolute(): return {CONF_STILL_IMAGE_URL: "relative_url"}, None - verify_ssl = info[CONF_VERIFY_SSL] + verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL] auth = generate_auth(info) try: async_client = get_async_client(hass, verify_ssl=verify_ssl) @@ -267,9 +268,9 @@ async def async_test_and_preview_stream( _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) raise InvalidStreamException("template_error") from err stream_options: dict[str, str | bool | float] = {} - if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): + if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT): stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport - if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True try: @@ -325,7 +326,7 @@ def register_still_preview(hass: HomeAssistant) -> None: class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for generic IP camera.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize Generic ConfigFlow.""" @@ -380,7 +381,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): user_input = DEFAULT_DATA.copy() return self.async_show_form( step_id="user", - data_schema=build_schema(user_input), + data_schema=self.add_suggested_values_to_schema(build_schema(), user_input), errors=errors, ) @@ -448,13 +449,19 @@ class GenericOptionsFlowHandler(OptionsFlow): self.preview_stream = None if not errors: data = { - CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False - ), **user_input, CONF_CONTENT_TYPE: still_format or self.config_entry.options.get(CONF_CONTENT_TYPE), } + if ( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS + not in user_input[SECTION_ADVANCED] + ): + data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = ( + self.config_entry.options[SECTION_ADVANCED].get( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False + ) + ) self.user_input = data # temporary preview for user to check the image self.preview_image_settings = data @@ -463,10 +470,12 @@ class GenericOptionsFlowHandler(OptionsFlow): user_input = self.user_input return self.async_show_form( step_id="init", - data_schema=build_schema( + data_schema=self.add_suggested_values_to_schema( + build_schema( + True, + self.show_advanced_options, + ), user_input or self.config_entry.options, - True, - self.show_advanced_options, ), errors=errors, ) @@ -582,7 +591,8 @@ async def ws_start_preview( _LOGGER.debug("Got preview still URL: %s", ha_still_url) if ha_stream := flow.preview_stream: - ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER) + # HLS player needs an absolute URL as base for constructing child playlist URLs + ha_stream_url = f"{get_url(hass)}{ha_stream.endpoint_url(HLS_PROVIDER)}" _LOGGER.debug("Got preview stream URL: %s", ha_stream_url) connection.send_message( diff --git a/homeassistant/components/generic/const.py b/homeassistant/components/generic/const.py index 4fd600db381..fa1037e5781 100644 --- a/homeassistant/components/generic/const.py +++ b/homeassistant/components/generic/const.py @@ -9,3 +9,4 @@ CONF_STILL_IMAGE_URL = "still_image_url" CONF_STREAM_SOURCE = "stream_source" CONF_FRAMERATE = "framerate" GET_IMAGE_TIMEOUT = 10 +SECTION_ADVANCED = "advanced" diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 836769970fb..b36cf6ea1fe 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -26,17 +26,24 @@ "step": { "user": { "data": { - "authentication": "Authentication", - "framerate": "Frame rate (Hz)", - "limit_refetch_to_url_change": "Limit refetch to URL change", "password": "[%key:common::config_flow::data::password%]", - "rtsp_transport": "RTSP transport protocol", "still_image_url": "Still image URL (e.g. http://...)", "stream_source": "Stream source URL (e.g. rtsp://...)", - "username": "[%key:common::config_flow::data::username%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + "username": "[%key:common::config_flow::data::username%]" }, - "description": "Enter the settings to connect to the camera." + "sections": { + "advanced": { + "data": { + "authentication": "Authentication", + "framerate": "Frame rate (Hz)", + "limit_refetch_to_url_change": "Limit refetch to URL change", + "rtsp_transport": "RTSP transport protocol", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.", + "name": "Advanced settings" + } + } }, "user_confirm": { "data": { @@ -70,19 +77,27 @@ "step": { "init": { "data": { - "authentication": "[%key:component::generic::config::step::user::data::authentication%]", - "framerate": "[%key:component::generic::config::step::user::data::framerate%]", - "limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]", "password": "[%key:common::config_flow::data::password%]", - "rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]", "still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]", "stream_source": "[%key:component::generic::config::step::user::data::stream_source%]", - "use_wallclock_as_timestamps": "Use wallclock as timestamps", - "username": "[%key:common::config_flow::data::username%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + "username": "[%key:common::config_flow::data::username%]" }, - "data_description": { - "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" + "sections": { + "advanced": { + "data": { + "authentication": "[%key:component::generic::config::step::user::sections::advanced::data::authentication%]", + "framerate": "[%key:component::generic::config::step::user::sections::advanced::data::framerate%]", + "limit_refetch_to_url_change": "[%key:component::generic::config::step::user::sections::advanced::data::limit_refetch_to_url_change%]", + "rtsp_transport": "[%key:component::generic::config::step::user::sections::advanced::data::rtsp_transport%]", + "use_wallclock_as_timestamps": "Use wallclock as timestamps", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" + }, + "description": "[%key:component::generic::config::step::user::sections::advanced::description%]", + "name": "[%key:component::generic::config::step::user::sections::advanced::name%]" + } } }, "user_confirm": { diff --git a/homeassistant/components/gentex_homelink/__init__.py b/homeassistant/components/gentex_homelink/__init__.py index 71a3dc4a2cd..831a2e0ff3e 100644 --- a/homeassistant/components/gentex_homelink/__init__.py +++ b/homeassistant/components/gentex_homelink/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import oauth2 from .const import DOMAIN -from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkData +from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator PLATFORMS: list[Platform] = [Platform.EVENT] @@ -44,9 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> ) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = HomeLinkData( - provider=provider, coordinator=coordinator, last_update_id=None - ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -54,5 +52,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool: """Unload a config entry.""" - await entry.runtime_data.coordinator.async_on_unload(None) + await entry.runtime_data.async_on_unload(None) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gentex_homelink/config_flow.py b/homeassistant/components/gentex_homelink/config_flow.py index a9041966827..512f9df5278 100644 --- a/homeassistant/components/gentex_homelink/config_flow.py +++ b/homeassistant/components/gentex_homelink/config_flow.py @@ -5,9 +5,10 @@ from typing import Any import botocore.exceptions from homelink.auth.srp_auth import SRPAuth +import jwt import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler @@ -34,12 +35,10 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> config_entries.ConfigFlowResult: + ) -> ConfigFlowResult: """Ask for username and password.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) - srp_auth = SRPAuth() try: tokens = await self.hass.async_add_executor_job( @@ -48,12 +47,17 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): user_input[CONF_PASSWORD], ) except botocore.exceptions.ClientError: - _LOGGER.exception("Error authenticating homelink account") errors["base"] = "srp_auth_failed" except Exception: _LOGGER.exception("An unexpected error occurred") errors["base"] = "unknown" else: + access_token = jwt.decode( + tokens["AuthenticationResult"]["AccessToken"], + options={"verify_signature": False}, + ) + await self.async_set_unique_id(access_token["sub"]) + self._abort_if_unique_id_configured() self.external_data = {"tokens": tokens} return await self.async_step_creation() diff --git a/homeassistant/components/gentex_homelink/coordinator.py b/homeassistant/components/gentex_homelink/coordinator.py index 85eb6200ed9..9e03b16fc79 100644 --- a/homeassistant/components/gentex_homelink/coordinator.py +++ b/homeassistant/components/gentex_homelink/coordinator.py @@ -1,12 +1,10 @@ -"""Makes requests to the state server and stores the resulting data so that the buttons can access it.""" +"""Establish MQTT connection and listen for event data.""" from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from functools import partial -import logging -from typing import TYPE_CHECKING, TypedDict +from typing import TypedDict from homelink.model.device import Device from homelink.mqtt_provider import MQTTProvider @@ -15,24 +13,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.util.ssl import get_default_context -if TYPE_CHECKING: - from .event import HomeLinkEventEntity - -_LOGGER = logging.getLogger(__name__) - -type HomeLinkConfigEntry = ConfigEntry[HomeLinkData] +type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator] type EventCallback = Callable[[HomeLinkEventData], None] -@dataclass -class HomeLinkData: - """Class for HomeLink integration runtime data.""" - - provider: MQTTProvider - coordinator: HomeLinkCoordinator - last_update_id: str | None - - class HomeLinkEventData(TypedDict): """Data for a single event.""" @@ -61,7 +45,6 @@ class HomeLinkCoordinator: self.config_entry = config_entry self.provider = provider self.device_data: list[Device] = [] - self.buttons: list[HomeLinkEventEntity] = [] self._listeners: dict[str, EventCallback] = {} @callback @@ -72,11 +55,11 @@ class HomeLinkCoordinator: self._listeners[target_event_id] = update_callback return partial(self.__async_remove_listener_internal, target_event_id) - def __async_remove_listener_internal(self, listener_id: str): + def __async_remove_listener_internal(self, listener_id: str) -> None: del self._listeners[listener_id] @callback - def async_handle_state_data(self, data: dict[str, HomeLinkEventData]): + def async_handle_state_data(self, data: dict[str, HomeLinkEventData]) -> None: """Notify listeners.""" for button_id, event in data.items(): if listener := self._listeners.get(button_id): @@ -86,7 +69,7 @@ class HomeLinkCoordinator: """Refresh data for the first time when a config entry is setup.""" await self._async_setup() - async def async_on_unload(self, _event): + async def async_on_unload(self, _event) -> None: """Disconnect and unregister when unloaded.""" await self.provider.disable() @@ -96,14 +79,12 @@ class HomeLinkCoordinator: await self.discover_devices() self.provider.listen(self.on_message) - async def discover_devices(self): + async def discover_devices(self) -> None: """Discover devices and build the Entities.""" self.device_data = await self.provider.discover() - def on_message( - self: HomeLinkCoordinator, _topic: str, message: HomeLinkMQTTMessage - ): - "MQTT Callback function." + def on_message(self, _topic: str, message: HomeLinkMQTTMessage) -> None: + """MQTT Callback function.""" if message["type"] == "state": self.hass.add_job(self.async_handle_state_data, message["data"]) if message["type"] == "requestSync": diff --git a/homeassistant/components/gentex_homelink/event.py b/homeassistant/components/gentex_homelink/event.py index 4cee09acef8..213502c9970 100644 --- a/homeassistant/components/gentex_homelink/event.py +++ b/homeassistant/components/gentex_homelink/event.py @@ -3,30 +3,27 @@ from __future__ import annotations from homeassistant.components.event import EventDeviceClass, EventEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, EVENT_PRESSED -from .coordinator import HomeLinkCoordinator, HomeLinkEventData +from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkEventData async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomeLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add the entities for the binary sensor.""" - coordinator = config_entry.runtime_data.coordinator - for device in coordinator.device_data: - buttons = [ - HomeLinkEventEntity(b.id, b.name, device.id, device.name, coordinator) - for b in device.buttons - ] - coordinator.buttons.extend(buttons) + """Add the entities for the event platform.""" + coordinator = config_entry.runtime_data - async_add_entities(coordinator.buttons) + async_add_entities( + HomeLinkEventEntity(coordinator, button.id, button.name, device.id, device.name) + for device in coordinator.device_data + for button in device.buttons + ) # Updates are centralized by the coordinator. @@ -42,17 +39,17 @@ class HomeLinkEventEntity(EventEntity): def __init__( self, - id: str, + coordinator: HomeLinkCoordinator, + button_id: str, param_name: str, device_id: str, device_name: str, - coordinator: HomeLinkCoordinator, ) -> None: """Initialize the event entity.""" - self.id: str = id - self._attr_name: str = param_name - self._attr_unique_id: str = id + self.button_id = button_id + self._attr_name = param_name + self._attr_unique_id = button_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, name=device_name, @@ -65,7 +62,7 @@ class HomeLinkEventEntity(EventEntity): await super().async_added_to_hass() self.async_on_remove( self.coordinator.async_add_event_listener( - self._handle_event_data_update, self.id + self._handle_event_data_update, self.button_id ) ) @@ -76,8 +73,4 @@ class HomeLinkEventEntity(EventEntity): if update_data["requestId"] != self.last_request_id: self._trigger_event(EVENT_PRESSED) self.last_request_id = update_data["requestId"] - - self.async_write_ha_state() - - async def async_update(self): - """Request early polling. Left intentionally blank because it's not possible in this implementation.""" + self.async_write_ha_state() diff --git a/homeassistant/components/gentex_homelink/oauth2.py b/homeassistant/components/gentex_homelink/oauth2.py index 55bbc4ddf9b..1432051fef5 100644 --- a/homeassistant/components/gentex_homelink/oauth2.py +++ b/homeassistant/components/gentex_homelink/oauth2.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementation): """Base class to abstract OAuth2 authentication.""" - def __init__(self, hass: HomeAssistant, domain) -> None: + def __init__(self, hass: HomeAssistant, domain: str) -> None: """Initialize the SRP Auth implementation.""" self.hass = hass @@ -45,16 +45,13 @@ class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementatio async def async_resolve_external_data(self, external_data) -> dict: """Format the token from the source appropriately for HomeAssistant.""" tokens = external_data["tokens"] - new_token = {} - new_token["access_token"] = tokens["AuthenticationResult"]["AccessToken"] - new_token["refresh_token"] = tokens["AuthenticationResult"]["RefreshToken"] - new_token["token_type"] = tokens["AuthenticationResult"]["TokenType"] - new_token["expires_in"] = tokens["AuthenticationResult"]["ExpiresIn"] - new_token["expires_at"] = ( - time.time() + tokens["AuthenticationResult"]["ExpiresIn"] - ) - - return new_token + return { + "access_token": tokens["AuthenticationResult"]["AccessToken"], + "refresh_token": tokens["AuthenticationResult"]["RefreshToken"], + "token_type": tokens["AuthenticationResult"]["TokenType"], + "expires_in": tokens["AuthenticationResult"]["ExpiresIn"], + "expires_at": (time.time() + tokens["AuthenticationResult"]["ExpiresIn"]), + } async def _token_request(self, data: dict) -> dict: """Make a token request.""" diff --git a/homeassistant/components/geocaching/manifest.json b/homeassistant/components/geocaching/manifest.json index 4617bd1c57b..557617d1020 100644 --- a/homeassistant/components/geocaching/manifest.json +++ b/homeassistant/components/geocaching/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/geocaching", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["geocachingapi==0.3.0"] } diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 9b242a8cc99..a4bbfdeff6f 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -54,7 +54,11 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=gios.station_name, - data=user_input, + # CONF_NAME is still used, but its value is preserved + # primarily for backward compatibility. This allows older + # versions of the software to read the entry data without + # raising errors. + data={**user_input, CONF_NAME: gios.station_name}, ) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" @@ -79,8 +83,7 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): sort=True, mode=SelectSelectorMode.DROPDOWN, ), - ), - vol.Optional(CONF_NAME, default=self.hass.config.location_name): str, + ) } ) diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index eb0dd82eb67..c80557da55f 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass import logging +from typing import TYPE_CHECKING from aiohttp.client_exceptions import ClientConnectorError from gios import Gios @@ -12,10 +13,12 @@ from gios.exceptions import GiosError from gios.model import GiosSensors from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_TIMEOUT, DOMAIN, SCAN_INTERVAL +from .const import API_TIMEOUT, DOMAIN, MANUFACTURER, SCAN_INTERVAL, URL _LOGGER = logging.getLogger(__name__) @@ -51,6 +54,21 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): update_interval=SCAN_INTERVAL, ) + station_id = gios.station_id + if TYPE_CHECKING: + # Station ID is Optional in the library, but here we know it is set for sure + # so we can safely assert it is not None for type checking purposes + # Gios instance is created only with a valid station ID in the async_setup_entry. + assert station_id is not None + + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(station_id))}, + manufacturer=MANUFACTURER, + name=config_entry.data[CONF_NAME], + configuration_url=URL.format(station_id=station_id), + ) + async def _async_update_data(self) -> GiosSensors: """Update data via library.""" try: diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index a0511e84536..7fb6fcf431c 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -15,10 +15,9 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,8 +35,6 @@ from .const import ( ATTR_SO2, ATTRIBUTION, DOMAIN, - MANUFACTURER, - URL, ) from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator @@ -184,8 +181,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a GIOS entities from a config_entry.""" - name = entry.data[CONF_NAME] - coordinator = entry.runtime_data.coordinator # Due to the change of the attribute name of one sensor, it is necessary to migrate # the unique_id to the new name. @@ -208,7 +203,7 @@ async def async_setup_entry( for description in SENSOR_TYPES: if getattr(coordinator.data, description.key) is None: continue - sensors.append(GiosSensor(name, coordinator, description)) + sensors.append(GiosSensor(coordinator, description)) async_add_entities(sensors) @@ -222,19 +217,13 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): def __init__( self, - name: str, coordinator: GiosDataUpdateCoordinator, description: GiosSensorEntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(coordinator.gios.station_id))}, - manufacturer=MANUFACTURER, - name=name, - configuration_url=URL.format(station_id=coordinator.gios.station_id), - ) + + self._attr_device_info = coordinator.device_info if description.subkey: self._attr_unique_id = ( f"{coordinator.gios.station_id}-{description.key}-{description.subkey}" diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index f7c82929c1c..da9c246600a 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -11,11 +11,9 @@ "step": { "user": { "data": { - "name": "[%key:common::config_flow::data::name%]", "station_id": "Measuring station" }, "data_description": { - "name": "Config entry name, by default, this is the name of your Home Assistant instance.", "station_id": "The name of the measuring station where the environmental data is collected." }, "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index e202f805ec6..7486c802fc6 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@timmo001", "@ludeeus"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/github", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiogithubapi"], "requirements": ["aiogithubapi==24.6.0"] diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index e129a375df2..1646b04cedb 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@engrbm87"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/glances", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["glances_api"], "requirements": ["glances-api==0.8.0"] diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 35c0c6fb70e..489be878043 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" # When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA) # in script/hassfest/docker.py. -RECOMMENDED_VERSION = "1.9.12" +RECOMMENDED_VERSION = "1.9.13" diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index 238c145302a..d7332994320 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -12,6 +12,7 @@ "homekit": { "models": ["iSmartGate"] }, + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["ismartgate"], "requirements": ["ismartgate==5.0.2"] diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 2f04ee3982f..b658dbca636 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@mletenay", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/goodwe", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["goodwe"], "requirements": ["goodwe==0.4.8"] diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index c828b971b99..837263474a3 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.2"] } diff --git a/homeassistant/components/google_air_quality/manifest.json b/homeassistant/components/google_air_quality/manifest.json index 22789aceb92..084151d2667 100644 --- a/homeassistant/components/google_air_quality/manifest.json +++ b/homeassistant/components/google_air_quality/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["google_air_quality_api"], "quality_scale": "bronze", - "requirements": ["google_air_quality_api==2.0.0"] + "requirements": ["google_air_quality_api==2.0.2"] } diff --git a/homeassistant/components/google_air_quality/strings.json b/homeassistant/components/google_air_quality/strings.json index a3c32f1b6ba..6ed0a11e041 100644 --- a/homeassistant/components/google_air_quality/strings.json +++ b/homeassistant/components/google_air_quality/strings.json @@ -88,16 +88,16 @@ "1b_good_air_quality": "1B - Good air quality", "2_cyan": "2 - Cyan", "2_light_green": "2 - Light green", - "2_orange": "4 - Orange", - "2_red": "5 - Red", - "2_yellow": "3 - Yellow", "2a_acceptable_air_quality": "2A - Acceptable air quality", "2b_acceptable_air_quality": "2B - Acceptable air quality", "3_green": "3 - Green", + "3_yellow": "3 - Yellow", "3a_aggravated_air_quality": "3A - Aggravated air quality", "3b_bad_air_quality": "3B - Bad air quality", + "4_orange": "4 - Orange", "4_yellow_watch": "4 - Yellow/Watch", "5_orange_alert": "5 - Orange/Alert", + "5_red": "5 - Red", "6_red_alert": "6 - Red/Alert+", "10_33": "10-33% of guideline", "33_66": "33-66% of guideline", diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 54ad66a2379..593d827864d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -908,12 +908,21 @@ class StartStopTrait(_Trait): } if domain in COVER_VALVE_DOMAINS: + assumed_state_or_set_position = bool( + ( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & COVER_VALVE_SET_POSITION_FEATURE[domain] + ) + or self.state.attributes.get(ATTR_ASSUMED_STATE) + ) + return { "isRunning": state in ( COVER_VALVE_STATES[domain]["closing"], COVER_VALVE_STATES[domain]["opening"], ) + or assumed_state_or_set_position } raise NotImplementedError(f"Unsupported domain {domain}") @@ -975,11 +984,23 @@ class StartStopTrait(_Trait): """Execute a StartStop command.""" domain = self.state.domain if command == COMMAND_START_STOP: + assumed_state_or_set_position = bool( + ( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & COVER_VALVE_SET_POSITION_FEATURE[domain] + ) + or self.state.attributes.get(ATTR_ASSUMED_STATE) + ) + if params["start"] is False: - if self.state.state in ( - COVER_VALVE_STATES[domain]["closing"], - COVER_VALVE_STATES[domain]["opening"], - ) or self.state.attributes.get(ATTR_ASSUMED_STATE): + if ( + self.state.state + in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) + or assumed_state_or_set_position + ): await self.hass.services.async_call( domain, SERVICE_STOP_COVER_VALVE[domain], @@ -992,7 +1013,14 @@ class StartStopTrait(_Trait): ERR_ALREADY_STOPPED, f"{FRIENDLY_DOMAIN[domain]} is already stopped", ) - else: + elif ( + self.state.state + in ( + COVER_VALVE_STATES[domain]["open"], + COVER_VALVE_STATES[domain]["closed"], + ) + or assumed_state_or_set_position + ): await self.hass.services.async_call( domain, SERVICE_TOGGLE_COVER_VALVE[domain], diff --git a/homeassistant/components/google_mail/const.py b/homeassistant/components/google_mail/const.py index 523182df072..816437b98c8 100644 --- a/homeassistant/components/google_mail/const.py +++ b/homeassistant/components/google_mail/const.py @@ -7,6 +7,7 @@ ATTR_CC = "cc" ATTR_ENABLED = "enabled" ATTR_END = "end" ATTR_FROM = "from" +ATTR_ALIAS_FROM = "alias_from" ATTR_ME = "me" ATTR_MESSAGE = "message" ATTR_PLAIN_TEXT = "plain_text" diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index 73c99d54ff3..cc9dd59503a 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 from email.mime.text import MIMEText +from email.utils import formataddr from typing import Any from googleapiclient.http import HttpRequest @@ -17,10 +18,20 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .api import AsyncConfigEntryAuth -from .const import ATTR_BCC, ATTR_CC, ATTR_FROM, ATTR_ME, ATTR_SEND, DATA_AUTH +from .const import ( + ATTR_ALIAS_FROM, + ATTR_BCC, + ATTR_CC, + ATTR_FROM, + ATTR_ME, + ATTR_SEND, + DATA_AUTH, + DOMAIN, +) async def async_get_service( @@ -47,7 +58,17 @@ class GMailNotificationService(BaseNotificationService): email = MIMEText(message, "html") if to_addrs := kwargs.get(ATTR_TARGET): email["To"] = ", ".join(to_addrs) - email["From"] = data.get(ATTR_FROM, ATTR_ME) + + email_from = data.get(ATTR_FROM, ATTR_ME) + if alias := data.get(ATTR_ALIAS_FROM): + if email_from == ATTR_ME: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_from_for_alias", + ) + email["From"] = formataddr((alias, email_from)) + else: + email["From"] = email_from email["Subject"] = title email[ATTR_CC] = ", ".join(data.get(ATTR_CC, [])) email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, [])) @@ -57,9 +78,9 @@ class GMailNotificationService(BaseNotificationService): msg: HttpRequest users = (await self.auth.get_resource()).users() if data.get(ATTR_SEND) is False: - msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body}) + msg = users.drafts().create(userId=email_from, body={ATTR_MESSAGE: body}) else: if not to_addrs: raise ValueError("recipient address required") - msg = users.messages().send(userId=email["From"], body=body) + msg = users.messages().send(userId=email_from, body=body) await self.hass.async_add_executor_job(msg.execute) diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index a4b5cf9a4d7..c76800b2c64 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -47,6 +47,11 @@ } } }, + "exceptions": { + "missing_from_for_alias": { + "message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email" + } + }, "services": { "set_vacation": { "description": "Sets vacation responder settings for Google Mail.", diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index 9a2e7bc13f4..85b9d060a11 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_photos", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["google_photos_library_api"], "requirements": ["google-photos-library-api==0.12.1"] diff --git a/homeassistant/components/google_tasks/manifest.json b/homeassistant/components/google_tasks/manifest.json index 08f2a54d051..290c439e7ef 100644 --- a/homeassistant/components/google_tasks/manifest.json +++ b/homeassistant/components/google_tasks/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_tasks", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["google-api-python-client==2.71.0"] } diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index 74c015c5345..f26c7fe17db 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@eifinger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/google_travel_time", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["google", "homeassistant.helpers.location"], "requirements": ["google-maps-routing==0.6.15"] diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index bdc7cb4ea84..696194266f4 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -138,6 +138,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", + "integration_type": "device", "iot_class": "local_push", "requirements": ["govee-ble==0.44.0"] } diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 350614fffbe..f3dfa0ada7d 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -10,6 +10,8 @@ from requests import RequestException from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( AUTH_API_TOKEN, @@ -19,14 +21,25 @@ from .const import ( DEFAULT_PLANT_ID, DEFAULT_URL, DEPRECATED_URLS, + DOMAIN, LOGIN_INVALID_AUTH_CODE, PLATFORMS, ) from .coordinator import GrowattConfigEntry, GrowattCoordinator from .models import GrowattRuntimeData +from .services import async_register_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Growatt Server component.""" + # Register services + await async_register_services(hass) + return True + def get_device_list_classic( api: growattServer.GrowattApi, config: Mapping[str, str] diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index e2f04ad05f3..65fdfb05417 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -46,3 +46,8 @@ ERROR_INVALID_AUTH = "invalid_auth" # Config flow abort reasons ABORT_NO_PLANTS = "no_plants" + +# Battery modes for TOU (Time of Use) settings +BATT_MODE_LOAD_FIRST = 0 +BATT_MODE_BATTERY_FIRST = 1 +BATT_MODE_GRID_FIRST = 2 diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 9756a7b57b4..c6f6b6b3c8b 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -12,10 +12,17 @@ import growattServer from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DEFAULT_URL, DOMAIN +from .const import ( + BATT_MODE_BATTERY_FIRST, + BATT_MODE_GRID_FIRST, + BATT_MODE_LOAD_FIRST, + DEFAULT_URL, + DOMAIN, +) from .models import GrowattRuntimeData if TYPE_CHECKING: @@ -247,3 +254,134 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.previous_values[variable] = return_value return return_value + + async def update_time_segment( + self, segment_id: int, batt_mode: int, start_time, end_time, enabled: bool + ) -> None: + """Update an inverter time segment. + + Args: + segment_id: Time segment ID (1-9) + batt_mode: Battery mode (0=load first, 1=battery first, 2=grid first) + start_time: Start time (datetime.time object) + end_time: End time (datetime.time object) + enabled: Whether the segment is enabled + """ + _LOGGER.debug( + "Updating time segment %d for device %s (mode=%d, %s-%s, enabled=%s)", + segment_id, + self.device_id, + batt_mode, + start_time, + end_time, + enabled, + ) + + if self.api_version != "v1": + raise ServiceValidationError( + "Updating time segments requires token authentication" + ) + + try: + # Use V1 API for token authentication + # The library's _process_response will raise GrowattV1ApiError if error_code != 0 + await self.hass.async_add_executor_job( + self.api.min_write_time_segment, + self.device_id, + segment_id, + batt_mode, + start_time, + end_time, + enabled, + ) + except growattServer.GrowattV1ApiError as err: + raise HomeAssistantError(f"API error updating time segment: {err}") from err + + # Update coordinator's cached data without making an API call (avoids rate limit) + if self.data: + # Update the time segment data in the cache + self.data[f"forcedTimeStart{segment_id}"] = start_time.strftime("%H:%M") + self.data[f"forcedTimeStop{segment_id}"] = end_time.strftime("%H:%M") + self.data[f"time{segment_id}Mode"] = batt_mode + self.data[f"forcedStopSwitch{segment_id}"] = 1 if enabled else 0 + + # Notify entities of the updated data (no API call) + self.async_set_updated_data(self.data) + + async def read_time_segments(self) -> list[dict]: + """Read time segments from an inverter. + + Returns: + List of dictionaries containing segment information + """ + _LOGGER.debug("Reading time segments for device %s", self.device_id) + + if self.api_version != "v1": + raise ServiceValidationError( + "Reading time segments requires token authentication" + ) + + # Ensure we have current data + if not self.data: + _LOGGER.debug("Coordinator data not available, triggering refresh") + await self.async_refresh() + + time_segments = [] + + # Extract time segments from coordinator data + for i in range(1, 10): # Segments 1-9 + segment = self._parse_time_segment(i) + time_segments.append(segment) + + return time_segments + + def _parse_time_segment(self, segment_id: int) -> dict: + """Parse a single time segment from coordinator data.""" + # Get raw time values - these should always be present from the API + start_time_raw = self.data.get(f"forcedTimeStart{segment_id}") + end_time_raw = self.data.get(f"forcedTimeStop{segment_id}") + + # Handle 'null' or empty values from API + if start_time_raw in ("null", None, ""): + start_time_raw = "0:0" + if end_time_raw in ("null", None, ""): + end_time_raw = "0:0" + + # Format times with leading zeros (HH:MM) + start_time = self._format_time(str(start_time_raw)) + end_time = self._format_time(str(end_time_raw)) + + # Get battery mode + batt_mode_int = int( + self.data.get(f"time{segment_id}Mode", BATT_MODE_LOAD_FIRST) + ) + + # Map numeric mode to string key (matches update_time_segment input format) + mode_map = { + BATT_MODE_LOAD_FIRST: "load_first", + BATT_MODE_BATTERY_FIRST: "battery_first", + BATT_MODE_GRID_FIRST: "grid_first", + } + batt_mode = mode_map.get(batt_mode_int, "load_first") + + # Get enabled status + enabled = bool(int(self.data.get(f"forcedStopSwitch{segment_id}", 0))) + + return { + "segment_id": segment_id, + "start_time": start_time, + "end_time": end_time, + "batt_mode": batt_mode, + "enabled": enabled, + } + + def _format_time(self, time_raw: str) -> str: + """Format time string to HH:MM format.""" + try: + parts = str(time_raw).split(":") + hour = int(parts[0]) + minute = int(parts[1]) + except (ValueError, IndexError): + return "00:00" + else: + return f"{hour:02d}:{minute:02d}" diff --git a/homeassistant/components/growatt_server/icons.json b/homeassistant/components/growatt_server/icons.json new file mode 100644 index 00000000000..091ab642760 --- /dev/null +++ b/homeassistant/components/growatt_server/icons.json @@ -0,0 +1,10 @@ +{ + "services": { + "read_time_segments": { + "service": "mdi:clock-outline" + }, + "update_time_segment": { + "service": "mdi:clock-edit" + } + } +} diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 45dc93d2444..71ee6e96c05 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@johanzander"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["growattServer"], "requirements": ["growattServer==1.7.1"] diff --git a/homeassistant/components/growatt_server/sensor/mix.py b/homeassistant/components/growatt_server/sensor/mix.py index b741a589b8f..910ec447b23 100644 --- a/homeassistant/components/growatt_server/sensor/mix.py +++ b/homeassistant/components/growatt_server/sensor/mix.py @@ -27,6 +27,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_battery_charge_lifetime", @@ -42,6 +43,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_battery_discharge_lifetime", @@ -57,6 +59,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epvToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_solar_generation_lifetime", @@ -72,6 +75,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pDischarge1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_battery_voltage", @@ -101,6 +105,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="elocalLoadToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_load_consumption_lifetime", @@ -116,6 +121,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="etoGridToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_export_to_grid_lifetime", @@ -132,6 +138,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="chargePower", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_load_consumption", @@ -139,6 +146,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pLocalLoad", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_wattage_pv_1", @@ -146,6 +154,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pPv1", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_wattage_pv_2", @@ -153,6 +162,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pPv2", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_wattage_pv_all", @@ -160,6 +170,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="ppv", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_export_to_grid", @@ -167,6 +178,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pactogrid", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_import_from_grid", @@ -174,6 +186,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pactouser", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_battery_discharge_kw", @@ -181,6 +194,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pdisCharge1", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="mix_grid_voltage", @@ -196,6 +210,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eCharge", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_load_consumption_solar_today", @@ -203,6 +218,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_self_consumption_today", @@ -210,6 +226,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eChargeToday1", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_load_consumption_battery_today", @@ -217,6 +234,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="echarge1", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="mix_import_from_grid_today", @@ -224,6 +242,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="etouser", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), # This sensor is manually created using the most recent X-Axis value from the chartData GrowattSensorEntityDescription( diff --git a/homeassistant/components/growatt_server/sensor/tlx.py b/homeassistant/components/growatt_server/sensor/tlx.py index 298170531de..e3689fbf7d1 100644 --- a/homeassistant/components/growatt_server/sensor/tlx.py +++ b/homeassistant/components/growatt_server/sensor/tlx.py @@ -79,6 +79,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="ppv1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -122,6 +123,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="ppv2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -165,6 +167,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="ppv3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -208,6 +211,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="ppv4", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -234,6 +238,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="ppv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -258,6 +263,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pac", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -323,6 +329,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="bdc1DischargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="tlx_battery_1_discharge_total", @@ -339,6 +346,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="bdc2DischargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="tlx_battery_2_discharge_total", @@ -372,6 +380,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="bdc1ChargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="tlx_battery_1_charge_total", @@ -388,6 +397,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="bdc2ChargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="tlx_battery_2_charge_total", @@ -445,6 +455,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pacToLocalLoad", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -453,6 +464,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pacToUserTotal", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -461,6 +473,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pacToGridTotal", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -545,6 +558,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="psystem", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), GrowattSensorEntityDescription( @@ -553,6 +567,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="pself", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, precision=1, ), ) diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index 578745c8610..a1eb898ae1c 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -50,5 +50,6 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="nominalPower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/growatt_server/services.py b/homeassistant/components/growatt_server/services.py new file mode 100644 index 00000000000..ecdae8ed3f6 --- /dev/null +++ b/homeassistant/components/growatt_server/services.py @@ -0,0 +1,169 @@ +"""Service handlers for Growatt Server integration.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from .const import ( + BATT_MODE_BATTERY_FIRST, + BATT_MODE_GRID_FIRST, + BATT_MODE_LOAD_FIRST, + DOMAIN, +) + +if TYPE_CHECKING: + from .coordinator import GrowattCoordinator + + +async def async_register_services(hass: HomeAssistant) -> None: + """Register services for Growatt Server integration.""" + + def get_min_coordinators() -> dict[str, GrowattCoordinator]: + """Get all MIN coordinators with V1 API from loaded config entries.""" + min_coordinators: dict[str, GrowattCoordinator] = {} + + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.state != ConfigEntryState.LOADED: + continue + + # Add MIN coordinators from this entry + for coord in entry.runtime_data.devices.values(): + if coord.device_type == "min" and coord.api_version == "v1": + min_coordinators[coord.device_id] = coord + + return min_coordinators + + def get_coordinator(device_id: str) -> GrowattCoordinator: + """Get coordinator by device_id. + + Args: + device_id: Device registry ID (not serial number) + """ + # Get current coordinators (they may have changed since service registration) + min_coordinators = get_min_coordinators() + + if not min_coordinators: + raise ServiceValidationError( + "No MIN devices with token authentication are configured. " + "Services require MIN devices with V1 API access." + ) + + # Device registry ID provided - map to serial number + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + + if not device_entry: + raise ServiceValidationError(f"Device '{device_id}' not found") + + # Extract serial number from device identifiers + serial_number = None + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + serial_number = identifier[1] + break + + if not serial_number: + raise ServiceValidationError( + f"Device '{device_id}' is not a Growatt device" + ) + + # Find coordinator by serial number + if serial_number not in min_coordinators: + raise ServiceValidationError( + f"MIN device '{serial_number}' not found or not configured for services" + ) + + return min_coordinators[serial_number] + + async def handle_update_time_segment(call: ServiceCall) -> None: + """Handle update_time_segment service call.""" + segment_id: int = int(call.data["segment_id"]) + batt_mode_str: str = call.data["batt_mode"] + start_time_str: str = call.data["start_time"] + end_time_str: str = call.data["end_time"] + enabled: bool = call.data["enabled"] + device_id: str = call.data["device_id"] + + # Validate segment_id range + if not 1 <= segment_id <= 9: + raise ServiceValidationError( + f"segment_id must be between 1 and 9, got {segment_id}" + ) + + # Validate and convert batt_mode string to integer + valid_modes = { + "load_first": BATT_MODE_LOAD_FIRST, + "battery_first": BATT_MODE_BATTERY_FIRST, + "grid_first": BATT_MODE_GRID_FIRST, + } + if batt_mode_str not in valid_modes: + raise ServiceValidationError( + f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'" + ) + batt_mode: int = valid_modes[batt_mode_str] + + # Convert time strings to datetime.time objects + # UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds) + try: + # Take only HH:MM part (ignore seconds if present) + start_parts = start_time_str.split(":") + start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}" + start_time = datetime.strptime(start_time_hhmm, "%H:%M").time() + except (ValueError, IndexError) as err: + raise ServiceValidationError( + "start_time must be in HH:MM or HH:MM:SS format" + ) from err + + try: + # Take only HH:MM part (ignore seconds if present) + end_parts = end_time_str.split(":") + end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}" + end_time = datetime.strptime(end_time_hhmm, "%H:%M").time() + except (ValueError, IndexError) as err: + raise ServiceValidationError( + "end_time must be in HH:MM or HH:MM:SS format" + ) from err + + # Get the appropriate MIN coordinator + coordinator: GrowattCoordinator = get_coordinator(device_id) + + await coordinator.update_time_segment( + segment_id, + batt_mode, + start_time, + end_time, + enabled, + ) + + async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]: + """Handle read_time_segments service call.""" + device_id: str = call.data["device_id"] + + # Get the appropriate MIN coordinator + coordinator: GrowattCoordinator = get_coordinator(device_id) + + time_segments: list[dict[str, Any]] = await coordinator.read_time_segments() + + return {"time_segments": time_segments} + + # Register services without schema - services.yaml will provide UI definition + # Schema validation happens in the handler functions + hass.services.async_register( + DOMAIN, + "update_time_segment", + handle_update_time_segment, + supports_response=SupportsResponse.NONE, + ) + + hass.services.async_register( + DOMAIN, + "read_time_segments", + handle_read_time_segments, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/growatt_server/services.yaml b/homeassistant/components/growatt_server/services.yaml new file mode 100644 index 00000000000..318ab71aad0 --- /dev/null +++ b/homeassistant/components/growatt_server/services.yaml @@ -0,0 +1,50 @@ +# Service definitions for Growatt Server integration + +update_time_segment: + fields: + segment_id: + required: true + example: 1 + selector: + number: + min: 1 + max: 9 + mode: box + batt_mode: + required: true + example: "load_first" + selector: + select: + options: + - "load_first" + - "battery_first" + - "grid_first" + translation_key: batt_mode + start_time: + required: true + example: "08:00" + selector: + time: + end_time: + required: true + example: "12:00" + selector: + time: + enabled: + required: true + example: true + selector: + boolean: + device_id: + required: true + selector: + device: + integration: growatt_server + +read_time_segments: + fields: + device_id: + required: true + selector: + device: + integration: growatt_server diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 4fc1b065843..5b7eebe556e 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -523,5 +523,56 @@ } } }, + "selector": { + "batt_mode": { + "options": { + "battery_first": "Battery first", + "grid_first": "Grid first", + "load_first": "Load first" + } + } + }, + "services": { + "read_time_segments": { + "description": "Read all time segments from a supported inverter.", + "fields": { + "device_id": { + "description": "The Growatt device to perform the action on.", + "name": "Device" + } + }, + "name": "Read time segments" + }, + "update_time_segment": { + "description": "Update a time segment for supported inverters.", + "fields": { + "batt_mode": { + "description": "Battery operation mode for this time segment.", + "name": "Battery mode" + }, + "device_id": { + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" + }, + "enabled": { + "description": "Whether this time segment is active.", + "name": "Enabled" + }, + "end_time": { + "description": "End time for the segment (HH:MM format).", + "name": "End time" + }, + "segment_id": { + "description": "Time segment ID (1-9).", + "name": "Segment ID" + }, + "start_time": { + "description": "Start time for the segment (HH:MM format).", + "name": "Start time" + } + }, + "name": "Update time segment" + } + }, "title": "Growatt Server" } diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json index b1e503e5e28..d9d70035c88 100644 --- a/homeassistant/components/hanna/manifest.json +++ b/homeassistant/components/hanna/manifest.json @@ -4,7 +4,8 @@ "codeowners": ["@bestycame"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hanna", + "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hanna-cloud==0.0.6"] + "requirements": ["hanna-cloud==0.0.7"] } diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 9f72c9c2d37..abda5b74522 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["remote"], "documentation": "https://www.home-assistant.io/integrations/harmony", + "integration_type": "device", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], "requirements": ["aioharmony==0.5.3"], diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index ae72546a10d..a1f30276d1f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio from contextlib import suppress from datetime import datetime -from functools import partial import logging import os import re @@ -42,24 +41,9 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.deprecation import ( - DeprecatedConstant, - all_with_deprecated_constants, - check_if_deprecated_constant, - deprecated_function, - dir_with_deprecated_constants, -) from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.hassio import ( - get_supervisor_ip as _get_supervisor_ip, - is_hassio as _is_hassio, -) from homeassistant.helpers.issue_registry import IssueSeverity -from homeassistant.helpers.service_info.hassio import ( - HassioServiceInfo as _HassioServiceInfo, -) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task from homeassistant.util.dt import now @@ -134,14 +118,6 @@ from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) -get_supervisor_ip = deprecated_function( - "homeassistant.helpers.hassio.get_supervisor_ip", breaks_in_ha_version="2025.11" -)(_get_supervisor_ip) -_DEPRECATED_HassioServiceInfo = DeprecatedConstant( - _HassioServiceInfo, - "homeassistant.helpers.service_info.hassio.HassioServiceInfo", - "2025.11", -) # If new platforms are added, be sure to import them above # so we do not make other components that depend on hassio @@ -302,19 +278,6 @@ def hostname_from_addon_slug(addon_slug: str) -> str: return addon_slug.replace("_", "-") -@callback -@deprecated_function( - "homeassistant.helpers.hassio.is_hassio", breaks_in_ha_version="2025.11" -) -@bind_hass -def is_hassio(hass: HomeAssistant) -> bool: - """Return true if Hass.io is loaded. - - Async friendly. - """ - return _is_hassio(hass) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up the Hass.io component.""" # Check local setup @@ -628,11 +591,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(ADDONS_COORDINATOR, None) return unload_ok - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index 9d3b622a877..169b1900393 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@eifinger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/here_travel_time", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"], "requirements": ["here-routing==1.2.0", "here-transit==1.2.1"] diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py index dbf7991b3c4..d1f539470e2 100644 --- a/homeassistant/components/hikvision/__init__.py +++ b/homeassistant/components/hikvision/__init__.py @@ -1 +1,87 @@ -"""The hikvision component.""" +"""The Hikvision integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from pyhik.hikvision import HikCamera +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR] + + +@dataclass +class HikvisionData: + """Data class for Hikvision runtime data.""" + + camera: HikCamera + device_id: str + device_name: str + device_type: str + + +type HikvisionConfigEntry = ConfigEntry[HikvisionData] + + +async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> bool: + """Set up Hikvision from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + ssl = entry.data[CONF_SSL] + + protocol = "https" if ssl else "http" + url = f"{protocol}://{host}" + + try: + camera = await hass.async_add_executor_job( + HikCamera, url, port, username, password, ssl + ) + except requests.exceptions.RequestException as err: + raise ConfigEntryNotReady(f"Unable to connect to {host}") from err + + device_id = camera.get_id + if device_id is None: + raise ConfigEntryNotReady(f"Unable to get device ID from {host}") + + device_name = camera.get_name or host + device_type = camera.get_type or "Camera" + + entry.runtime_data = HikvisionData( + camera=camera, + device_id=device_id, + device_name=device_name, + device_type=device_type, + ) + + # Start the event stream + await hass.async_add_executor_job(camera.start_stream) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + # Stop the event stream + await hass.async_add_executor_job(entry.runtime_data.camera.disconnect) + + return unload_ok diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 76cca5079e4..f0917c769bf 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import timedelta import logging +from typing import Any -from pyhik.hikvision import HikCamera import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -13,6 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE, @@ -23,27 +23,27 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_USERNAME, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.dt import utcnow -_LOGGER = logging.getLogger(__name__) +from . import HikvisionConfigEntry +from .const import DEFAULT_PORT, DOMAIN CONF_IGNORED = "ignored" -DEFAULT_PORT = 80 -DEFAULT_IGNORED = False DEFAULT_DELAY = 0 +DEFAULT_IGNORED = False -ATTR_DELAY = "delay" - -DEVICE_CLASS_MAP = { +# Device class mapping for Hikvision event types +DEVICE_CLASS_MAP: dict[str, BinarySensorDeviceClass | None] = { "Motion": BinarySensorDeviceClass.MOTION, "Line Crossing": BinarySensorDeviceClass.MOTION, "Field Detection": BinarySensorDeviceClass.MOTION, @@ -67,6 +67,8 @@ DEVICE_CLASS_MAP = { "Entering Region": BinarySensorDeviceClass.MOTION, } +_LOGGER = logging.getLogger(__name__) + CUSTOMIZE_SCHEMA = vol.Schema( { vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean, @@ -88,214 +90,144 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( } ) +PARALLEL_UPDATES = 0 -def setup_platform( + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Hikvision binary sensor devices.""" - name = config.get(CONF_NAME) - host = config[CONF_HOST] - port = config[CONF_PORT] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] + """Set up the Hikvision binary sensor platform from YAML.""" + # Trigger the import flow to migrate YAML config to config entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) - customize = config[CONF_CUSTOMIZE] - - protocol = "https" if config[CONF_SSL] else "http" - - url = f"{protocol}://{host}" - - data = HikvisionData(hass, url, port, name, username, password) - - if data.sensors is None: - _LOGGER.error("Hikvision event stream has no data, unable to set up") + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Hikvision", + }, + ) return - entities = [] - - for sensor, channel_list in data.sensors.items(): - for channel in channel_list: - # Build sensor name, then parse customize config. - if data.type == "NVR": - sensor_name = f"{sensor.replace(' ', '_')}_{channel[1]}" - else: - sensor_name = sensor.replace(" ", "_") - - custom = customize.get(sensor_name.lower(), {}) - ignore = custom.get(CONF_IGNORED) - delay = custom.get(CONF_DELAY) - - _LOGGER.debug( - "Entity: %s - %s, Options - Ignore: %s, Delay: %s", - data.name, - sensor_name, - ignore, - delay, - ) - if not ignore: - entities.append( - HikvisionBinarySensor(hass, sensor, channel[1], data, delay) - ) - - add_entities(entities) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Hikvision", + }, + ) -class HikvisionData: - """Hikvision device event stream object.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: HikvisionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hikvision binary sensors from a config entry.""" + data = entry.runtime_data + camera = data.camera - def __init__(self, hass, url, port, name, username, password): - """Initialize the data object.""" - self._url = url - self._port = port - self._name = name - self._username = username - self._password = password + sensors = camera.current_event_states + if sensors is None or not sensors: + _LOGGER.warning("Hikvision device has no sensors available") + return - # Establish camera - self.camdata = HikCamera(self._url, self._port, self._username, self._password) - - if self._name is None: - self._name = self.camdata.get_name - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik) - - def stop_hik(self, event): - """Shutdown Hikvision subscriptions and subscription thread on exit.""" - self.camdata.disconnect() - - def start_hik(self, event): - """Start Hikvision event stream thread.""" - self.camdata.start_stream() - - @property - def sensors(self): - """Return list of available sensors and their states.""" - return self.camdata.current_event_states - - @property - def cam_id(self): - """Return device id.""" - return self.camdata.get_id - - @property - def name(self): - """Return device name.""" - return self._name - - @property - def type(self): - """Return device type.""" - return self.camdata.get_type - - def get_attributes(self, sensor, channel): - """Return attribute list for sensor/channel.""" - return self.camdata.fetch_attributes(sensor, channel) + async_add_entities( + HikvisionBinarySensor( + entry=entry, + sensor_type=sensor_type, + channel=channel_info[1], + ) + for sensor_type, channel_list in sensors.items() + for channel_info in channel_list + ) class HikvisionBinarySensor(BinarySensorEntity): """Representation of a Hikvision binary sensor.""" + _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, hass, sensor, channel, cam, delay): - """Initialize the binary_sensor.""" - self._hass = hass - self._cam = cam - self._sensor = sensor + def __init__( + self, + entry: HikvisionConfigEntry, + sensor_type: str, + channel: int, + ) -> None: + """Initialize the binary sensor.""" + self._data = entry.runtime_data + self._camera = self._data.camera + self._sensor_type = sensor_type self._channel = channel - if self._cam.type == "NVR": - self._name = f"{self._cam.name} {sensor} {channel}" + # Build unique ID + self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}" + + # Build entity name based on device type + if self._data.device_type == "NVR": + self._attr_name = f"{sensor_type} {channel}" else: - self._name = f"{self._cam.name} {sensor}" + self._attr_name = sensor_type - self._id = f"{self._cam.cam_id}.{sensor}.{channel}" + # Device info for device registry + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._data.device_id)}, + name=self._data.device_name, + manufacturer="Hikvision", + model=self._data.device_type, + ) - if delay is None: - self._delay = 0 - else: - self._delay = delay + # Set device class + self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type) - self._timer = None + # Callback ID for pyhik + self._callback_id = f"{self._data.device_id}.{sensor_type}.{channel}" - # Register callback function with pyHik - self._cam.camdata.add_update_callback(self._update_callback, self._id) - - def _sensor_state(self): - """Extract sensor state.""" - return self._cam.get_attributes(self._sensor, self._channel)[0] - - def _sensor_last_update(self): - """Extract sensor last update time.""" - return self._cam.get_attributes(self._sensor, self._channel)[3] + def _get_sensor_attributes(self) -> tuple[bool, Any, Any, Any]: + """Get sensor attributes from camera.""" + return self._camera.fetch_attributes(self._sensor_type, self._channel) @property - def name(self): - """Return the name of the Hikvision sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor is on.""" - return self._sensor_state() + return self._get_sensor_attributes()[0] @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - try: - return DEVICE_CLASS_MAP[self._sensor] - except KeyError: - # Sensor must be unknown to us, add as generic - return None - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attr = {ATTR_LAST_TRIP_TIME: self._sensor_last_update()} + attrs = self._get_sensor_attributes() + return {ATTR_LAST_TRIP_TIME: attrs[3]} - if self._delay != 0: - attr[ATTR_DELAY] = self._delay + async def async_added_to_hass(self) -> None: + """Register callback when entity is added.""" + await super().async_added_to_hass() - return attr + # Register callback with pyhik + self._camera.add_update_callback(self._update_callback, self._callback_id) - def _update_callback(self, msg): - """Update the sensor's state, if needed.""" - _LOGGER.debug("Callback signal from: %s", msg) - - if self._delay > 0 and not self.is_on: - # Set timer to wait until updating the state - def _delay_update(now): - """Timer callback for sensor update.""" - _LOGGER.debug( - "%s Called delayed (%ssec) update", self._name, self._delay - ) - self.schedule_update_ha_state() - self._timer = None - - if self._timer is not None: - self._timer() - self._timer = None - - self._timer = track_point_in_utc_time( - self._hass, _delay_update, utcnow() + timedelta(seconds=self._delay) - ) - - elif self._delay > 0 and self.is_on: - # For delayed sensors kill any callbacks on true events and update - if self._timer is not None: - self._timer() - self._timer = None - - self.schedule_update_ha_state() - - else: - self.schedule_update_ha_state() + @callback + def _update_callback(self, msg: str) -> None: + """Update the sensor's state when callback is triggered.""" + self.async_write_ha_state() diff --git a/homeassistant/components/hikvision/config_flow.py b/homeassistant/components/hikvision/config_flow.py new file mode 100644 index 00000000000..a38cf8d8ed5 --- /dev/null +++ b/homeassistant/components/hikvision/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for Hikvision integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyhik.hikvision import HikCamera +import requests +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.helpers.typing import ConfigType + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hikvision.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + ssl = user_input[CONF_SSL] + + protocol = "https" if ssl else "http" + url = f"{protocol}://{host}" + + try: + camera = await self.hass.async_add_executor_job( + HikCamera, url, port, username, password, ssl + ) + except requests.exceptions.RequestException: + _LOGGER.exception("Error connecting to Hikvision device") + errors["base"] = "cannot_connect" + else: + device_id = camera.get_id + device_name = camera.get_name + if device_id is None: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device_name or host, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_SSL: ssl, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=False): bool, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + host = import_data[CONF_HOST] + port = import_data.get(CONF_PORT, DEFAULT_PORT) + username = import_data[CONF_USERNAME] + password = import_data[CONF_PASSWORD] + ssl = import_data.get(CONF_SSL, False) + name = import_data.get(CONF_NAME) + + protocol = "https" if ssl else "http" + url = f"{protocol}://{host}" + + try: + camera = await self.hass.async_add_executor_job( + HikCamera, url, port, username, password, ssl + ) + except requests.exceptions.RequestException: + _LOGGER.exception( + "Error connecting to Hikvision device during import, aborting" + ) + return self.async_abort(reason="cannot_connect") + + device_id = camera.get_id + device_name = camera.get_name + if device_id is None: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + + _LOGGER.warning( + "Importing Hikvision config from configuration.yaml for %s", host + ) + + return self.async_create_entry( + title=name or device_name or host, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_SSL: ssl, + }, + ) diff --git a/homeassistant/components/hikvision/const.py b/homeassistant/components/hikvision/const.py new file mode 100644 index 00000000000..14e6a1808b7 --- /dev/null +++ b/homeassistant/components/hikvision/const.py @@ -0,0 +1,6 @@ +"""Constants for the Hikvision integration.""" + +DOMAIN = "hikvision" + +# Default values +DEFAULT_PORT = 80 diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index a0832732105..5e0c201945a 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -1,10 +1,12 @@ { "domain": "hikvision", "name": "Hikvision", - "codeowners": ["@mezz64"], + "codeowners": ["@mezz64", "@ptarjan"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hikvision", + "integration_type": "device", "iot_class": "local_push", "loggers": ["pyhik"], "quality_scale": "legacy", - "requirements": ["pyHik==0.3.2"] + "requirements": ["pyHik==0.3.4"] } diff --git a/homeassistant/components/hikvision/strings.json b/homeassistant/components/hikvision/strings.json new file mode 100644 index 00000000000..4501113d309 --- /dev/null +++ b/homeassistant/components/hikvision/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "Use SSL", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hikvision device", + "password": "The password for your Hikvision device", + "port": "The port number for the device (default is 80)", + "ssl": "Enable if your device uses HTTPS", + "username": "The username for your Hikvision device" + }, + "description": "Enter your Hikvision device connection details.", + "title": "Set up Hikvision device" + } + } + }, + "issues": { + "deprecated_yaml_import_issue": { + "description": "Configuring {integration_title} using YAML is deprecated and the import failed. Please remove the `{domain}` entry from your `configuration.yaml` file and set up the integration manually.", + "title": "YAML import failed" + } + } +} diff --git a/homeassistant/components/hisense_aehw4a1/manifest.json b/homeassistant/components/hisense_aehw4a1/manifest.json index 34791529291..2145ddbb91b 100644 --- a/homeassistant/components/hisense_aehw4a1/manifest.json +++ b/homeassistant/components/hisense_aehw4a1/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bannhead"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyaehw4a1"], "requirements": ["pyaehw4a1==0.3.9"] diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 4938e1dc1ad..a97be87c597 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["HHKBridge*"] }, + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], "requirements": ["pyhive-integration==1.0.7"] diff --git a/homeassistant/components/hko/manifest.json b/homeassistant/components/hko/manifest.json index 74718bb98c2..e1b9602324a 100644 --- a/homeassistant/components/hko/manifest.json +++ b/homeassistant/components/hko/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@MisterCommand"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hko", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["hko==0.3.2"] } diff --git a/homeassistant/components/hlk_sw16/manifest.json b/homeassistant/components/hlk_sw16/manifest.json index f4153e8021b..141261fbcc0 100644 --- a/homeassistant/components/hlk_sw16/manifest.json +++ b/homeassistant/components/hlk_sw16/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@jameshilliard"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hlk_sw16", + "integration_type": "device", "iot_class": "local_push", "loggers": ["hlk_sw16"], "requirements": ["hlk-sw16==0.0.9"] diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index d0892df399d..9583857660f 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -49,7 +49,7 @@ from homeassistant.helpers.service import ( from homeassistant.helpers.signal import KEY_HA_STOP from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.target import ( - TargetSelectorData, + TargetSelection, async_extract_referenced_entity_ids, ) from homeassistant.helpers.template import async_load_custom_templates @@ -115,7 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_handle_turn_service(service: ServiceCall) -> None: """Handle calls to homeassistant.turn_on/off.""" referenced = async_extract_referenced_entity_ids( - hass, TargetSelectorData(service.data) + hass, TargetSelection(service.data) ) all_referenced = referenced.referenced | referenced.indirectly_referenced diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 2d4ebff955b..ce08feaaebb 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -78,7 +78,7 @@ from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.start import async_at_started from homeassistant.helpers.target import ( - TargetSelectorData, + TargetSelection, async_extract_referenced_entity_ids, ) from homeassistant.helpers.typing import ConfigType @@ -483,7 +483,7 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def async_handle_homekit_unpair(service: ServiceCall) -> None: """Handle unpair HomeKit service call.""" referenced = async_extract_referenced_entity_ids( - hass, TargetSelectorData(service.data) + hass, TargetSelection(service.data) ) dev_reg = dr.async_get(hass) for device_id in referenced.referenced_devices: diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d1123ab5f7e..0757da7d22c 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@hahn-th"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["homematicip"], "requirements": ["homematicip==2.4.0"] diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index 0aee8f80078..6197ec73e20 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -16,7 +16,7 @@ from .entity import HomeWizardEntity def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P]( func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]: - """Decorate HomeWizard Energy calls to handle HomeWizardEnergy exceptions. + """Decorate HomeWizard calls to handle HomeWizardEnergy exceptions. A decorator that wraps the passed in function, catches HomeWizardEnergy errors, and reloads the integration when the API was disabled so the reauth flow is diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index a96eca28d8c..63500eb1f71 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -1,6 +1,6 @@ { "domain": "homewizard", - "name": "HomeWizard Energy", + "name": "HomeWizard", "codeowners": ["@DCSBL"], "config_flow": true, "dhcp": [ @@ -13,6 +13,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==9.3.0"], + "requirements": ["python-homewizard-energy==10.0.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/select.py b/homeassistant/components/homewizard/select.py index 2ae37883107..132eac87375 100644 --- a/homeassistant/components/homewizard/select.py +++ b/homeassistant/components/homewizard/select.py @@ -2,12 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from homewizard_energy import HomeWizardEnergy -from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry +from homewizard_energy.models import Batteries from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -21,69 +16,59 @@ from .helpers import homewizard_exception_handler PARALLEL_UPDATES = 1 -@dataclass(frozen=True, kw_only=True) -class HomeWizardSelectEntityDescription(SelectEntityDescription): - """Class describing HomeWizard select entities.""" - - available_fn: Callable[[DeviceResponseEntry], bool] - create_fn: Callable[[DeviceResponseEntry], bool] - current_fn: Callable[[DeviceResponseEntry], str | None] - set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]] - - -DESCRIPTIONS = [ - HomeWizardSelectEntityDescription( - key="battery_group_mode", - translation_key="battery_group_mode", - entity_category=EntityCategory.CONFIG, - entity_registry_enabled_default=False, - options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL], - available_fn=lambda x: x.batteries is not None, - create_fn=lambda x: x.batteries is not None, - current_fn=lambda x: x.batteries.mode if x.batteries else None, - set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)), - ), -] - - async def async_setup_entry( hass: HomeAssistant, entry: HomeWizardConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up HomeWizard select based on a config entry.""" - async_add_entities( - HomeWizardSelectEntity( - coordinator=entry.runtime_data, - description=description, + if entry.runtime_data.data.device.supports_batteries(): + async_add_entities( + [ + HomeWizardBatteryModeSelectEntity( + coordinator=entry.runtime_data, + ) + ] ) - for description in DESCRIPTIONS - if description.create_fn(entry.runtime_data.data) - ) -class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity): +class HomeWizardBatteryModeSelectEntity(HomeWizardEntity, SelectEntity): """Defines a HomeWizard select entity.""" - entity_description: HomeWizardSelectEntityDescription + entity_description: SelectEntityDescription def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - description: HomeWizardSelectEntityDescription, ) -> None: """Initialize the switch.""" super().__init__(coordinator) + + description = SelectEntityDescription( + key="battery_group_mode", + translation_key="battery_group_mode", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[ + str(mode) + for mode in (coordinator.data.device.supported_battery_modes() or []) + ], + ) + self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return self.entity_description.current_fn(self.coordinator.data) + return ( + self.coordinator.data.batteries.mode + if self.coordinator.data.batteries and self.coordinator.data.batteries.mode + else None + ) @homewizard_exception_handler async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.entity_description.set_fn(self.coordinator.api, option) + await self.coordinator.api.batteries(Batteries.Mode(option)) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 714e99a7f65..cea3d3a3e50 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -12,13 +12,13 @@ "wrong_device": "The configured device is not the same found on this IP address." }, "error": { - "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.", + "api_not_enabled": "The local API is disabled. Go to the HomeWizard app and enable the API in the device settings.", "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds", "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" }, "step": { "authorize": { - "description": "Press the button on the HomeWizard Energy device for two seconds, then select the button below.", + "description": "Press the button on the HomeWizard device for two seconds, then select the button below.", "title": "Authorize" }, "discovery_confirm": { @@ -30,7 +30,7 @@ "title": "Re-authenticate" }, "reauth_enable_api": { - "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." + "description": "The local API is disabled. Go to the HomeWizard app and enable the API in the device settings." }, "reconfigure": { "data": { @@ -46,9 +46,9 @@ "ip_address": "[%key:common::config_flow::data::ip%]" }, "data_description": { - "ip_address": "The IP address of your HomeWizard Energy device." + "ip_address": "The IP address of your HomeWizard device." }, - "description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.", + "description": "Enter the IP address of your HomeWizard device to integrate with Home Assistant.", "title": "Configure device" } } @@ -65,7 +65,9 @@ "state": { "standby": "Standby", "to_full": "Manual charge mode", - "zero": "Zero mode" + "zero": "Zero mode", + "zero_charge_only": "Zero mode (charge only)", + "zero_discharge_only": "Zero mode (discharge only)" } } }, @@ -172,7 +174,7 @@ "message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue." }, "communication_error": { - "message": "An error occurred while communicating with your HomeWizard Energy device" + "message": "An error occurred while communicating with your HomeWizard device" } }, "issues": { diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 1930b40583d..786475c26f7 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -1,4 +1,4 @@ -"""Creates HomeWizard Energy switch entities.""" +"""Creates HomeWizard switch entities.""" from __future__ import annotations diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json index 011c301d00d..77f83d3be0c 100644 --- a/homeassistant/components/homeworks/manifest.json +++ b/homeassistant/components/homeworks/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homeworks", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyhomeworks"], "requirements": ["pyhomeworks==1.1.2"] diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 4a597a6700a..1138be5a87c 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@rdfurman", "@mkmer"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/honeywell", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["somecomfort"], "requirements": ["AIOSomecomfort==0.0.35"] diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 63e9674565f..e4f211ffcee 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@scop", "@fphammerle"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huawei_lte", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": ["huawei-lte-api==1.11.0", "url-normalize==2.2.1"], diff --git a/homeassistant/components/huisbaasje/manifest.json b/homeassistant/components/huisbaasje/manifest.json index 7ea7be258b6..33e58bf4eb6 100644 --- a/homeassistant/components/huisbaasje/manifest.json +++ b/homeassistant/components/huisbaasje/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dennisschroer"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huisbaasje", + "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["energyflip"], "requirements": ["energyflip-client==0.2.2"] diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index ab3a400b106..bf85be1bb58 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -16,6 +16,7 @@ "homekit": { "models": ["PowerView"] }, + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiopvapi"], "requirements": ["aiopvapi==3.3.0"], diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index ce89717cd0c..a1ce1e118f4 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -11,6 +11,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"] } diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 79bfd9795cb..5620932bf88 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@frwickst", "@vincentwolsink"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", + "integration_type": "device", "iot_class": "cloud_polling", "requirements": ["huum==0.8.1"] } diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json index c18777613e8..3e23d2da8c9 100644 --- a/homeassistant/components/hvv_departures/manifest.json +++ b/homeassistant/components/hvv_departures/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@vigonotion"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hvv_departures", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pygti"], "requirements": ["pygti==0.9.4"] diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 703fed8d415..2ad8d8f36bd 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dknowles2", "@thomaskistler", "@ptcryan"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hydrawise", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], "requirements": ["pydrawise==2025.9.0"] diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json index 6c14b2ddf6c..5d66956c1e0 100644 --- a/homeassistant/components/hyperion/manifest.json +++ b/homeassistant/components/hyperion/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dermotduffy"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hyperion", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["hyperion"], "requirements": ["hyperion-py==0.7.6"], diff --git a/homeassistant/components/ialarm/manifest.json b/homeassistant/components/ialarm/manifest.json index 16c540222f7..d67f31f7f58 100644 --- a/homeassistant/components/ialarm/manifest.json +++ b/homeassistant/components/ialarm/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@RyuzakiKK"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ialarm", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyialarm"], "requirements": ["pyialarm==2.2.0"] diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 6e8ce312ad0..fea0531264a 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@flz"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iaqualink", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["iaqualink"], "requirements": ["iaqualink==0.6.0", "h2==4.3.0"], diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index d1d35def76b..d6b60d6da98 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -108,7 +108,7 @@ class IcloudAccount: if self.api.requires_2fa: # Trigger a new log in to ensure the user enters the 2FA code again. - raise PyiCloudFailedLoginException # noqa: TRY301 + raise PyiCloudFailedLoginException("2FA Required") # noqa: TRY301 except PyiCloudFailedLoginException: self.api = None diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index efcef15b4d0..f1d7dc47455 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -16,7 +16,7 @@ from pyicloud.exceptions import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.storage import Store @@ -155,8 +155,8 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN): CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, } - # If this is a password update attempt, update the entry instead of creating one - if step_id == "user": + # If this is a password update attempt, don't try and creating one + if self.source == SOURCE_USER: return self.async_create_entry(title=self._username, data=data) entry = await self.async_set_unique_id(self.unique_id) diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 0cf6b89d20c..318be5cca98 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Quentame", "@nzapponi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/icloud", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["keyrings.alt", "pyicloud"], "requirements": ["pyicloud==2.2.0"] diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json index 7bfb8f690c7..d73ee5a8e3a 100644 --- a/homeassistant/components/igloohome/manifest.json +++ b/homeassistant/components/igloohome/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@keithle888"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/igloohome", + "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", "requirements": ["igloohome-api==0.1.1"] diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 515fee0e721..91420694438 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/imap", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["aioimaplib"], "requirements": ["aioimaplib==2.0.1"] diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 9dfba7ebee2..8197d57008b 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bieniu"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", + "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", "requirements": ["imgw_pib==1.6.0"] diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 0df566066c1..ca74498f2a3 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -261,7 +261,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): if self._can_identify is None: try: - self._can_identify = await self._try_call(device.can_identify()) + await self._try_call(device.ensure_connected()) + self._can_identify = device.can_identify except AbortFlow as err: return self.async_abort(reason=err.reason) if self._can_identify: diff --git a/homeassistant/components/improv_ble/manifest.json b/homeassistant/components/improv_ble/manifest.json index 2815df5d7f5..144a8177c98 100644 --- a/homeassistant/components/improv_ble/manifest.json +++ b/homeassistant/components/improv_ble/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/improv_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-improv-ble-client==1.0.3"] + "requirements": ["py-improv-ble-client==2.0.1"] } diff --git a/homeassistant/components/inels/manifest.json b/homeassistant/components/inels/manifest.json index 2764983d5b2..c3caa55ba06 100644 --- a/homeassistant/components/inels/manifest.json +++ b/homeassistant/components/inels/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/inels", + "integration_type": "hub", "iot_class": "local_push", "mqtt": ["inels/status/#"], "quality_scale": "bronze", diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index f11bc22fdb3..1d2ce58ec47 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -61,6 +61,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", + "integration_type": "device", "iot_class": "local_push", "requirements": ["inkbird-ble==1.1.1"] } diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index c9127640250..a63b2509c97 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -14,10 +14,11 @@ } ], "documentation": "https://www.home-assistant.io/integrations/insteon", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.6.3", + "pyinsteon==1.6.4", "insteon-frontend-home-assistant==0.5.0" ], "single_config_entry": true, diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index d258c59eb9c..b54ba47ce57 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -9,6 +9,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/intellifire", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["intellifire4py"], "requirements": ["intellifire4py==4.2.1"] diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index 5fd178389d9..f6f9efb1632 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gtdiehl", "@jyavenard"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iotawatt", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["iotawattpy"], "requirements": ["ha-iotawattpy==0.1.2"] diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 971525e013f..95327d986e4 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"], "requirements": ["pyipma==3.0.9"] diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index cae3d31feb2..bbb29019906 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@engrbm87", "@cpfair"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", + "integration_type": "service", "iot_class": "calculated", "loggers": ["prayer_times_calculator"], "requirements": ["prayer-times-calculator-offline==1.0.3"] diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index 45d259c6ea2..0362f7d2224 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@shaiu"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/israel_rail", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], "requirements": ["israel-rail-api==0.1.4"] diff --git a/homeassistant/components/ista_ecotrend/manifest.json b/homeassistant/components/ista_ecotrend/manifest.json index 332eb5fd3ef..565c59cec98 100644 --- a/homeassistant/components/ista_ecotrend/manifest.json +++ b/homeassistant/components/ista_ecotrend/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ista_ecotrend", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyecotrend_ista"], "quality_scale": "gold", diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index 34a0b4444bb..80b2c1c2dad 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["iZone"] }, + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pizone"], "requirements": ["python-izone==1.2.9"] diff --git a/homeassistant/components/justnimbus/manifest.json b/homeassistant/components/justnimbus/manifest.json index 48fdad69ac8..b640d6d8b3b 100644 --- a/homeassistant/components/justnimbus/manifest.json +++ b/homeassistant/components/justnimbus/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@kvanzuijlen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/justnimbus", + "integration_type": "device", "iot_class": "cloud_polling", "requirements": ["justnimbus==0.7.4"] } diff --git a/homeassistant/components/kaleidescape/manifest.json b/homeassistant/components/kaleidescape/manifest.json index f38fd091a48..ee607829b7a 100644 --- a/homeassistant/components/kaleidescape/manifest.json +++ b/homeassistant/components/kaleidescape/manifest.json @@ -4,8 +4,9 @@ "codeowners": ["@SteveEasley"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kaleidescape", + "integration_type": "device", "iot_class": "local_push", - "requirements": ["pykaleidescape==1.0.1"], + "requirements": ["pykaleidescape==1.0.2"], "ssdp": [ { "deviceType": "schemas-upnp-org:device:Basic:1", diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 0751b40acd2..0b025912820 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@foxel"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["ndms2_client"], "requirements": ["ndms2-client==0.1.2"], diff --git a/homeassistant/components/kegtron/manifest.json b/homeassistant/components/kegtron/manifest.json index aa73cdd57db..f457278ab1e 100644 --- a/homeassistant/components/kegtron/manifest.json +++ b/homeassistant/components/kegtron/manifest.json @@ -11,6 +11,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/kegtron", + "integration_type": "device", "iot_class": "local_push", "requirements": ["kegtron-ble==1.0.2"] } diff --git a/homeassistant/components/kmtronic/manifest.json b/homeassistant/components/kmtronic/manifest.json index 4a037e679c8..8faf4d2772d 100644 --- a/homeassistant/components/kmtronic/manifest.json +++ b/homeassistant/components/kmtronic/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kmtronic", + "integration_type": "device", "iot_class": "local_push", "loggers": ["pykmtronic"], "requirements": ["pykmtronic==0.3.0"] diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 8a751ebfe0c..023fc06bab1 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -94,6 +94,8 @@ SERVICE_KNX_EVENT_REGISTER: Final = "event_register" SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register" SERVICE_KNX_READ: Final = "read" +REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue" + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" @@ -163,6 +165,7 @@ SUPPORTED_PLATFORMS_UI: Final = { Platform.CLIMATE, Platform.COVER, Platform.DATE, + Platform.FAN, Platform.DATETIME, Platform.LIGHT, Platform.SWITCH, @@ -217,3 +220,9 @@ class ClimateConf: FAN_MAX_STEP: Final = "fan_max_step" FAN_SPEED_MODE: Final = "fan_speed_mode" FAN_ZERO_MODE: Final = "fan_zero_mode" + + +class FanConf: + """Common config keys for fan.""" + + MAX_STEP: Final = "max_step" diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index c4379bcf869..89cacf1aa04 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -77,6 +77,11 @@ class _KnxEntityBase(Entity): """Store register state change callback and start device object.""" self._device.register_device_updated_cb(self.after_update_callback) self._device.xknx.devices.async_add(self._device) + if uid := self.unique_id: + self._knx_module.add_to_group_address_entities( + group_addresses=self._device.group_addresses(), + identifier=(self.platform_data.domain, uid), + ) # super call needed to have methods of multi-inherited classes called # eg. for restoring state (like _KNXSwitch) await super().async_added_to_hass() @@ -85,6 +90,11 @@ class _KnxEntityBase(Entity): """Disconnect device object when removed.""" self._device.unregister_device_updated_cb(self.after_update_callback) self._device.xknx.devices.async_remove(self._device) + if uid := self.unique_id: + self._knx_module.remove_from_group_address_entities( + group_addresses=self._device.group_addresses(), + identifier=(self.platform_data.domain, uid), + ) class KnxYamlEntity(_KnxEntityBase): diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 23f25dc8469..275f72ca50f 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -5,13 +5,17 @@ from __future__ import annotations import math from typing import Any, Final +from propcache.api import cached_property from xknx.devices import Fan as XknxFan from homeassistant import config_entries from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -19,10 +23,18 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule from .schema import FanSchema +from .storage.const import ( + CONF_ENTITY, + CONF_GA_OSCILLATION, + CONF_GA_SPEED, + CONF_GA_STEP, + CONF_SPEED, +) +from .storage.util import ConfigExtractor DEFAULT_PERCENTAGE: Final = 50 @@ -34,40 +46,36 @@ async def async_setup_entry( ) -> None: """Set up fan(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.FAN] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.FAN, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiFan, + ), + ) - async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config) + entities: list[_KnxFan] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN): + entities.extend( + KnxYamlFan(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN): + entities.extend( + KnxUiFan(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXFan(KnxYamlEntity, FanEntity): +class _KnxFan(FanEntity): """Representation of a KNX fan.""" _device: XknxFan - - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of KNX fan.""" - max_step = config.get(FanSchema.CONF_MAX_STEP) - super().__init__( - knx_module=knx_module, - device=XknxFan( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_speed=config.get(KNX_ADDRESS), - group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS), - group_address_oscillation=config.get( - FanSchema.CONF_OSCILLATION_ADDRESS - ), - group_address_oscillation_state=config.get( - FanSchema.CONF_OSCILLATION_STATE_ADDRESS - ), - max_step=max_step, - ), - ) - # FanSpeedMode.STEP if max_step is set - self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - - self._attr_unique_id = str(self._device.speed.group_address) + _step_range: tuple[int, int] | None async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" @@ -77,7 +85,7 @@ class KNXFan(KnxYamlEntity, FanEntity): else: await self._device.set_speed(percentage) - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" flags = ( @@ -103,7 +111,7 @@ class KNXFan(KnxYamlEntity, FanEntity): ) return self._device.current_speed - @property + @cached_property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" if self._step_range is None: @@ -134,3 +142,76 @@ class KNXFan(KnxYamlEntity, FanEntity): def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._device.current_oscillation + + +class KnxYamlFan(_KnxFan, KnxYamlEntity): + """Representation of a KNX fan configured from YAML.""" + + _device: XknxFan + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of KNX fan.""" + max_step = config.get(FanConf.MAX_STEP) + super().__init__( + knx_module=knx_module, + device=XknxFan( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_speed=config.get(KNX_ADDRESS), + group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS), + group_address_oscillation=config.get( + FanSchema.CONF_OSCILLATION_ADDRESS + ), + group_address_oscillation_state=config.get( + FanSchema.CONF_OSCILLATION_STATE_ADDRESS + ), + max_step=max_step, + ), + ) + # FanSpeedMode.STEP if max_step is set + self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + + self._attr_unique_id = str(self._device.speed.group_address) + + +class KnxUiFan(_KnxFan, KnxUiEntity): + """Representation of a KNX fan configured from UI.""" + + _device: XknxFan + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize of KNX fan.""" + knx_conf = ConfigExtractor(config[DOMAIN]) + # max_step is required for step mode, thus can be used to differentiate modes + max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP) + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + if max_step: + # step control + speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_STEP) + speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_STEP) + else: + # percentage control + speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_SPEED) + speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_SPEED) + + self._device = XknxFan( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address_speed=speed_write, + group_address_speed_state=speed_state, + group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION), + group_address_oscillation_state=knx_conf.get_state_and_passive( + CONF_GA_OSCILLATION + ), + max_step=max_step, + sync_state=knx_conf.get(CONF_SYNC_STATE), + ) + # FanSpeedMode.STEP if max_step is set + self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None diff --git a/homeassistant/components/knx/icons.json b/homeassistant/components/knx/icons.json index e13a41ba3c2..09a6c5b8977 100644 --- a/homeassistant/components/knx/icons.json +++ b/homeassistant/components/knx/icons.json @@ -21,6 +21,9 @@ "telegram_count": { "default": "mdi:plus-network" }, + "telegrams_data_secure_undecodable": { + "default": "mdi:lock-alert" + }, "telegrams_incoming": { "default": "mdi:upload-network" }, diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py index 8974cad1baa..42c14eae2a8 100644 --- a/homeassistant/components/knx/knx_module.py +++ b/homeassistant/components/knx/knx_module.py @@ -56,6 +56,7 @@ from .const import ( from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime from .project import KNXProject +from .repairs import data_secure_group_key_issue_dispatcher from .storage.config_store import KNXConfigStore from .telegrams import Telegrams @@ -107,8 +108,12 @@ class KNXModule: self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.group_address_entities: dict[ + DeviceGroupAddress, set[tuple[str, str]] # {(platform, unique_id),} + ] = {} self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() + self.entry.async_on_unload(data_secure_group_key_issue_dispatcher(self)) self.entry.async_on_unload( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) ) @@ -225,6 +230,29 @@ class KNXModule: threaded=True, ) + def add_to_group_address_entities( + self, + group_addresses: set[DeviceGroupAddress], + identifier: tuple[str, str], # (platform, unique_id) + ) -> None: + """Register entity in group_address_entities map.""" + for ga in group_addresses: + if ga not in self.group_address_entities: + self.group_address_entities[ga] = set() + self.group_address_entities[ga].add(identifier) + + def remove_from_group_address_entities( + self, + group_addresses: set[DeviceGroupAddress], + identifier: tuple[str, str], + ) -> None: + """Unregister entity from group_address_entities map.""" + for ga in group_addresses: + if ga in self.group_address_entities: + self.group_address_entities[ga].discard(identifier) + if not self.group_address_entities[ga]: + del self.group_address_entities[ga] + def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Call invoked after a KNX connection state change was received.""" self.connected = state == XknxConnectionState.CONNECTED diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 98910c77787..8c4bf261155 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,9 +9,9 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": [ - "xknx==3.12.0", + "xknx==3.13.0", "xknxproject==3.8.2", "knx-frontend==2025.10.31.195356" ], diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index 9e24cc1ce5b..a4a25e414b4 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -105,7 +105,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/knx/repairs.py b/homeassistant/components/knx/repairs.py new file mode 100644 index 00000000000..598bdc7d0a9 --- /dev/null +++ b/homeassistant/components/knx/repairs.py @@ -0,0 +1,175 @@ +"""Repairs for KNX integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial +from typing import TYPE_CHECKING, Any, Final + +import voluptuous as vol +from xknx.exceptions.exception import InvalidSecureConfiguration +from xknx.telegram import GroupAddress, IndividualAddress, Telegram + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir, selector +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +if TYPE_CHECKING: + from .knx_module import KNXModule + +from .const import ( + CONF_KNX_KNXKEY_PASSWORD, + DOMAIN, + REPAIR_ISSUE_DATA_SECURE_GROUP_KEY, + KNXConfigEntryData, +) +from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file +from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict + +CONF_KEYRING_FILE: Final = "knxkeys_file" + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id == REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: + return DataSecureGroupIssueRepairFlow() + # If KNX adds confirm-only repairs in the future, this should be changed + # to return a ConfirmRepairFlow instead of raising a ValueError + raise ValueError(f"unknown repair {issue_id}") + + +###################### +# DataSecure key issue +###################### + + +@callback +def data_secure_group_key_issue_dispatcher(knx_module: KNXModule) -> Callable[[], None]: + """Watcher for DataSecure group key issues.""" + return async_dispatcher_connect( + knx_module.hass, + signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, + target=partial(_data_secure_group_key_issue_handler, knx_module), + ) + + +@callback +def _data_secure_group_key_issue_handler( + knx_module: KNXModule, telegram: Telegram, telegram_dict: TelegramDict +) -> None: + """Handle DataSecure group key issue telegrams.""" + if telegram.destination_address not in knx_module.group_address_entities: + # Only report issues for configured group addresses + return + + issue_registry = ir.async_get(knx_module.hass) + new_ga = str(telegram.destination_address) + new_ia = str(telegram.source_address) + new_data = {new_ga: new_ia} + + if existing_issue := issue_registry.async_get_issue( + DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY + ): + assert isinstance(existing_issue.data, dict) + existing_data: dict[str, str] = existing_issue.data # type: ignore[assignment] + if new_ga in existing_data: + current_ias = existing_data[new_ga].split(", ") + if new_ia in current_ias: + return + current_ias = sorted([*current_ias, new_ia], key=IndividualAddress) + new_data[new_ga] = ", ".join(current_ias) + new_data_unsorted = existing_data | new_data + new_data = { + key: new_data_unsorted[key] + for key in sorted(new_data_unsorted, key=GroupAddress) + } + + issue_registry.async_get_or_create( + DOMAIN, + REPAIR_ISSUE_DATA_SECURE_GROUP_KEY, + data=new_data, # type: ignore[arg-type] + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key=REPAIR_ISSUE_DATA_SECURE_GROUP_KEY, + translation_placeholders={ + "addresses": "\n".join( + f"`{ga}` from {ias}" for ga, ias in new_data.items() + ), + "interface": str(knx_module.xknx.current_address), + }, + ) + + +class DataSecureGroupIssueRepairFlow(RepairsFlow): + """Handler for an issue fixing flow for outdated DataSecure keys.""" + + @callback + def _async_get_placeholders(self) -> dict[str, str]: + issue_registry = ir.async_get(self.hass) + issue = issue_registry.async_get_issue(self.handler, self.issue_id) + assert issue is not None + return issue.translation_placeholders or {} + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_secure_knxkeys() + + async def async_step_secure_knxkeys( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Manage upload of new KNX Keyring file.""" + errors: dict[str, str] = {} + + if user_input is not None: + password = user_input[CONF_KNX_KNXKEY_PASSWORD] + keyring = None + try: + keyring = await save_uploaded_knxkeys_file( + self.hass, + uploaded_file_id=user_input[CONF_KEYRING_FILE], + password=password, + ) + except InvalidSecureConfiguration: + errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" + + if not errors and keyring: + new_entry_data = KNXConfigEntryData( + knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}", + knxkeys_password=password, + ) + return self.finish_flow(new_entry_data) + + fields = { + vol.Required(CONF_KEYRING_FILE): selector.FileSelector( + config=selector.FileSelectorConfig(accept=".knxkeys") + ), + vol.Required(CONF_KNX_KNXKEY_PASSWORD): selector.TextSelector(), + } + return self.async_show_form( + step_id="secure_knxkeys", + data_schema=vol.Schema(fields), + description_placeholders=self._async_get_placeholders(), + errors=errors, + ) + + @callback + def finish_flow( + self, new_entry_data: KNXConfigEntryData + ) -> data_entry_flow.FlowResult: + """Finish the repair flow. Reload the config entry.""" + knx_config_entries = self.hass.config_entries.async_entries(DOMAIN) + if knx_config_entries: + config_entry = knx_config_entries[0] # single_config_entry + new_data = {**config_entry.data, **new_entry_data} + self.hass.config_entries.async_update_entry(config_entry, data=new_data) + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) + return self.async_create_entry(data={}) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index faf53162dfe..2adb3dec2c7 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -59,6 +59,7 @@ from .const import ( ClimateConf, ColorTempModes, CoverConf, + FanConf, FanZeroMode, ) from .validation import ( @@ -575,7 +576,6 @@ class FanSchema(KNXPlatformSchema): CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_OSCILLATION_ADDRESS = "oscillation_address" CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address" - CONF_MAX_STEP = "max_step" DEFAULT_NAME = "KNX Fan" @@ -586,7 +586,7 @@ class FanSchema(KNXPlatformSchema): vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator, vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator, - vol.Optional(CONF_MAX_STEP): cv.byte, + vol.Optional(FanConf.MAX_STEP): cv.byte, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 2a3732e7fcd..480f7d68db2 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -108,6 +108,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( + knx.xknx.connection_manager.cemi_count_incoming + knx.xknx.connection_manager.cemi_count_incoming_error, ), + KNXSystemEntityDescription( + key="telegrams_data_secure_undecodable", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.undecoded_data_secure, + ), ) diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index eac1dde1f10..76ffc6e0c7c 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -17,6 +17,8 @@ CONF_GA_DATE: Final = "ga_date" CONF_GA_DATETIME: Final = "ga_datetime" CONF_GA_TIME: Final = "ga_time" +CONF_GA_STEP: Final = "ga_step" + # Climate CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" @@ -42,11 +44,15 @@ CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal" # Cover CONF_GA_UP_DOWN: Final = "ga_up_down" CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" CONF_GA_POSITION_SET: Final = "ga_position_set" CONF_GA_POSITION_STATE: Final = "ga_position_state" CONF_GA_ANGLE: Final = "ga_angle" +# Fan +CONF_SPEED: Final = "speed" +CONF_GA_SPEED: Final = "ga_speed" +CONF_GA_OSCILLATION: Final = "ga_oscillation" + # Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7b742b63b59..24ae93b488b 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -28,6 +28,7 @@ from ..const import ( ClimateConf, ColorTempModes, CoverConf, + FanConf, FanZeroMode, ) from .const import ( @@ -62,6 +63,7 @@ from .const import ( CONF_GA_OP_MODE_PROTECTION, CONF_GA_OP_MODE_STANDBY, CONF_GA_OPERATION_MODE, + CONF_GA_OSCILLATION, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, @@ -69,6 +71,7 @@ from .const import ( CONF_GA_SATURATION, CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, + CONF_GA_SPEED, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, @@ -80,6 +83,7 @@ from .const import ( CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, CONF_IGNORE_AUTO_MODE, + CONF_SPEED, CONF_TARGET_TEMPERATURE, ) from .knx_selector import ( @@ -220,6 +224,42 @@ DATETIME_KNX_SCHEMA = vol.Schema( } ) +FAN_KNX_SCHEMA = vol.Schema( + { + vol.Required(CONF_SPEED): GroupSelect( + GroupSelectOption( + translation_key="percentage_mode", + schema={ + vol.Required(CONF_GA_SPEED): GASelector( + write_required=True, valid_dpt="5.001" + ), + }, + ), + GroupSelectOption( + translation_key="step_mode", + schema={ + vol.Required(CONF_GA_STEP): GASelector( + write_required=True, valid_dpt="5.010" + ), + vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=100, + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), + }, + ), + collapsible=False, + ), + vol.Optional(CONF_GA_OSCILLATION): GASelector( + write_required=True, valid_dpt="1" + ), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + } +) + @unique class LightColorMode(StrEnum): @@ -513,6 +553,7 @@ KNX_SCHEMA_FOR_PLATFORM = { Platform.COVER: COVER_KNX_SCHEMA, Platform.DATE: DATE_KNX_SCHEMA, Platform.DATETIME: DATETIME_KNX_SCHEMA, + Platform.FAN: FAN_KNX_SCHEMA, Platform.LIGHT: LIGHT_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA, Platform.TIME: TIME_KNX_SCHEMA, diff --git a/homeassistant/components/knx/storage/keyring.py b/homeassistant/components/knx/storage/keyring.py index 9e9cfda2b80..97ff21151df 100644 --- a/homeassistant/components/knx/storage/keyring.py +++ b/homeassistant/components/knx/storage/keyring.py @@ -10,9 +10,10 @@ from xknx.secure.keyring import Keyring, sync_load_keyring from homeassistant.components.file_upload import process_uploaded_file from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.storage import STORAGE_DIR -from ..const import DOMAIN +from ..const import DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY _LOGGER = logging.getLogger(__name__) @@ -45,4 +46,11 @@ async def save_uploaded_knxkeys_file( shutil.move(file_path, dest_file) return keyring - return await hass.async_add_executor_job(_process_upload) + keyring = await hass.async_add_executor_job(_process_upload) + + # If there is an existing DataSecure group key issue, remove it. + # GAs might not be DataSecure anymore after uploading a valid keyring, + # if they are, we raise the issue again when receiving a telegram. + ir.async_delete_issue(hass, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY) + + return keyring diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 6c61a737570..6a1f689d44f 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -460,6 +460,41 @@ } } }, + "fan": { + "description": "The KNX fan platform is used as an interface to fan actuators.", + "knx": { + "ga_oscillation": { + "description": "Toggle oscillation of the fan.", + "label": "Oscillation" + }, + "speed": { + "description": "Control the speed of the fan.", + "ga_speed": { + "description": "Group address to control the current speed of the fan as a percentage value.", + "label": "Speed" + }, + "ga_step": { + "description": "Group address to control the current speed step.", + "label": "Step" + }, + "max_step": { + "description": "Number of discrete fan speed steps (Off excluded).", + "label": "Fan steps" + }, + "options": { + "percentage_mode": { + "description": "Set the fan speed as a percentage value (0-100%).", + "label": "Percentage" + }, + "step_mode": { + "description": "Set the fan speed in discrete steps.", + "label": "Steps" + } + }, + "title": "Fan speed" + } + } + }, "header": "Create new entity", "light": { "description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", @@ -639,6 +674,10 @@ "name": "Telegrams", "unit_of_measurement": "telegrams" }, + "telegrams_data_secure_undecodable": { + "name": "Undecodable Data Secure telegrams", + "unit_of_measurement": "[%key:component::knx::entity::sensor::telegrams_incoming_error::unit_of_measurement%]" + }, "telegrams_incoming": { "name": "Incoming telegrams", "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]" @@ -671,6 +710,30 @@ "message": "Invalid type for `knx.send` service: {type}" } }, + "issues": { + "data_secure_group_key_issue": { + "fix_flow": { + "error": { + "keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]" + }, + "step": { + "secure_knxkeys": { + "data": { + "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", + "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" + }, + "data_description": { + "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", + "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" + }, + "description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).", + "title": "Update KNX Keyring" + } + } + }, + "title": "KNX Data Secure telegrams can't be decrypted" + } + }, "options": { "step": { "communication_settings": { diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index df49c84b6d5..1f01c9c78fe 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -26,6 +26,9 @@ STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json" # dispatcher signal for KNX interface device triggers SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram") +SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType( + "knx_data_secure_issue_telegram" +) class DecodedTelegramPayload(TypedDict): @@ -74,6 +77,11 @@ class Telegrams: match_for_outgoing=True, ) ) + self._xknx_data_secure_group_key_issue_cb_handle = ( + xknx.telegram_queue.register_data_secure_group_key_issue_cb( + self._xknx_data_secure_group_key_issue_cb, + ) + ) self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size) self.last_ga_telegrams: dict[str, TelegramDict] = {} @@ -107,6 +115,14 @@ class Telegrams: self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict) + def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None: + """Handle telegrams with undecodable data secure payload from xknx.""" + telegram_dict = self.telegram_to_dict(telegram) + self.recent_telegrams.append(telegram_dict) + async_dispatcher_send( + self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict + ) + def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: """Convert a Telegram to a dict.""" dst_name = "" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 387a6e9e6de..262bfb82492 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from contextlib import ExitStack from functools import wraps import inspect from typing import TYPE_CHECKING, Any, Final, overload @@ -34,7 +35,11 @@ from .storage.entity_store_validation import ( validate_entity_data, ) from .storage.serialize import get_serialized_schema -from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict +from .telegrams import ( + SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, + SIGNAL_KNX_TELEGRAM, + TelegramDict, +) if TYPE_CHECKING: from .knx_module import KNXModule @@ -334,11 +339,23 @@ def ws_subscribe_telegram( telegram_dict, ) - connection.subscriptions[msg["id"]] = async_dispatcher_connect( - hass, - signal=SIGNAL_KNX_TELEGRAM, - target=forward_telegram, + stack = ExitStack() + stack.callback( + async_dispatcher_connect( + hass, + signal=SIGNAL_KNX_TELEGRAM, + target=forward_telegram, + ) ) + stack.callback( + async_dispatcher_connect( + hass, + signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, + target=forward_telegram, + ) + ) + + connection.subscriptions[msg["id"]] = stack.close connection.send_result(msg["id"]) diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json index 708a15e0fc2..7a36c240ff6 100644 --- a/homeassistant/components/kodi/manifest.json +++ b/homeassistant/components/kodi/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@OnFreund"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kodi", + "integration_type": "service", "iot_class": "local_push", "loggers": ["jsonrpc_async", "jsonrpc_base", "jsonrpc_websocket", "pykodi"], "requirements": ["pykodi==0.2.7"], diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json index 09352fa7a80..7bdbb7f703c 100644 --- a/homeassistant/components/kostal_plenticore/manifest.json +++ b/homeassistant/components/kostal_plenticore/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@stegm"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["kostal"], "requirements": ["pykoplenti==1.3.0"] diff --git a/homeassistant/components/kraken/manifest.json b/homeassistant/components/kraken/manifest.json index fed16a673b5..e7543e39b88 100644 --- a/homeassistant/components/kraken/manifest.json +++ b/homeassistant/components/kraken/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@eifinger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kraken", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["krakenex", "pykrakenapi"], "requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"] diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index a838c47c698..436e9408995 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -10,6 +10,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/kulersky", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], "requirements": ["pykulersky==0.5.8"] diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 38e64274deb..fee97b9ed79 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@IceBotYT"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["lacrosse_view"], "requirements": ["lacrosse-view==1.1.1"] diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index dcf0b803364..6ef4d397572 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN +from .const import CONF_INSTALLATION_KEY, CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( LaMarzoccoBluetoothUpdateCoordinator, LaMarzoccoConfigEntry, @@ -118,45 +118,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - _LOGGER.info( "Bluetooth device not found during lamarzocco setup, continuing with cloud only" ) - try: - settings = await cloud_client.get_thing_settings(serial) - except AuthFail as ex: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from ex - except (RequestNotSuccessful, TimeoutError) as ex: - _LOGGER.debug(ex, exc_info=True) - if not bluetooth_client: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, translation_key="api_error" - ) from ex - _LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True) - else: - gateway_version = version.parse( - settings.firmwares[FirmwareType.GATEWAY].build_version - ) - if gateway_version < version.parse("v5.0.9"): - # incompatible gateway firmware, create an issue - ir.async_create_issue( - hass, - DOMAIN, - "unsupported_gateway_firmware", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="unsupported_gateway_firmware", - translation_placeholders={"gateway_version": str(gateway_version)}, - ) - # Update BLE Token if exists - if settings.ble_auth_token: - hass.config_entries.async_update_entry( - entry, - data={ - **entry.data, - CONF_TOKEN: settings.ble_auth_token, - }, + async def _get_thing_settings() -> None: + """Get thing settings from cloud to verify details and get BLE token.""" + try: + settings = await cloud_client.get_thing_settings(serial) + except AuthFail as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from ex + except (RequestNotSuccessful, TimeoutError) as ex: + _LOGGER.debug(ex, exc_info=True) + if not bluetooth_client: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex + _LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True) + else: + gateway_version = version.parse( + settings.firmwares[FirmwareType.GATEWAY].build_version ) + if gateway_version < version.parse("v5.0.9"): + # incompatible gateway firmware, create an issue + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_gateway_firmware", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_gateway_firmware", + translation_placeholders={"gateway_version": str(gateway_version)}, + ) + # Update BLE Token if exists + if settings.ble_auth_token: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_TOKEN: settings.ble_auth_token, + }, + ) + + if not (local_mode := entry.options.get(CONF_OFFLINE_MODE, False)): + await _get_thing_settings() + device = LaMarzoccoMachine( serial_number=entry.unique_id, cloud_client=cloud_client, @@ -170,12 +176,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), ) - await asyncio.gather( - coordinators.config_coordinator.async_config_entry_first_refresh(), - coordinators.settings_coordinator.async_config_entry_first_refresh(), - coordinators.schedule_coordinator.async_config_entry_first_refresh(), - coordinators.statistics_coordinator.async_config_entry_first_refresh(), - ) + if not local_mode: + await asyncio.gather( + coordinators.config_coordinator.async_config_entry_first_refresh(), + coordinators.settings_coordinator.async_config_entry_first_refresh(), + coordinators.schedule_coordinator.async_config_entry_first_refresh(), + coordinators.statistics_coordinator.async_config_entry_first_refresh(), + ) + + if local_mode and not bluetooth_client: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="bluetooth_required_offline" + ) # bt coordinator only if bluetooth client is available # and after the initial refresh of the config coordinator diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index ab99fbbc63f..9e953d93044 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -47,7 +47,7 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import create_client_session -from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN +from .const import CONF_INSTALLATION_KEY, CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry CONF_MACHINE = "machine" @@ -379,19 +379,30 @@ class LmOptionsFlowHandler(OptionsFlowWithReload): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options for the custom component.""" - if user_input: - return self.async_create_entry(title="", data=user_input) + errors: dict[str, str] = {} + if user_input: + if user_input.get(CONF_OFFLINE_MODE) and not user_input.get( + CONF_USE_BLUETOOTH + ): + errors[CONF_USE_BLUETOOTH] = "bluetooth_required_offline" + else: + return self.async_create_entry(title="", data=user_input) options_schema = vol.Schema( { vol.Optional( CONF_USE_BLUETOOTH, default=self.config_entry.options.get(CONF_USE_BLUETOOTH, True), ): cv.boolean, + vol.Optional( + CONF_OFFLINE_MODE, + default=self.config_entry.options.get(CONF_OFFLINE_MODE, False), + ): cv.boolean, } ) return self.async_show_form( step_id="init", data_schema=options_schema, + errors=errors, ) diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py index 680557d85f1..e2fd86b6397 100644 --- a/homeassistant/components/lamarzocco/const.py +++ b/homeassistant/components/lamarzocco/const.py @@ -6,3 +6,4 @@ DOMAIN: Final = "lamarzocco" CONF_USE_BLUETOOTH: Final = "use_bluetooth" CONF_INSTALLATION_KEY: Final = "installation_key" +CONF_OFFLINE_MODE: Final = "offline_mode" diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 25021fbec2a..084d9107151 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import CONF_OFFLINE_MODE, DOMAIN SCAN_INTERVAL = timedelta(seconds=60) SETTINGS_UPDATE_INTERVAL = timedelta(hours=8) @@ -49,7 +49,8 @@ type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData] class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): """Base class for La Marzocco coordinators.""" - _default_update_interval = SCAN_INTERVAL + _default_update_interval: timedelta | None = SCAN_INTERVAL + _ignore_offline_mode = False config_entry: LaMarzoccoConfigEntry update_success = False @@ -60,12 +61,17 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): device: LaMarzoccoMachine, ) -> None: """Initialize coordinator.""" + update_interval = self._default_update_interval + if not self._ignore_offline_mode and entry.options.get( + CONF_OFFLINE_MODE, False + ): + update_interval = None super().__init__( hass, _LOGGER, config_entry=entry, name=DOMAIN, - update_interval=self._default_update_interval, + update_interval=update_interval, ) self.device = device self._websocket_task: Task | None = None @@ -214,6 +220,8 @@ class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): class LaMarzoccoBluetoothUpdateCoordinator(LaMarzoccoUpdateCoordinator): """Class to handle fetching data from the La Marzocco Bluetooth API centrally.""" + _ignore_offline_mode = True + async def _internal_async_setup(self) -> None: """Initial setup for Bluetooth coordinator.""" await self.device.get_model_info_from_bluetooth() diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index f8270909bae..ac59d954c37 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -28,6 +28,9 @@ } }, "number": { + "bbw_dose": { + "default": "mdi:weight-gram" + }, "coffee_temp": { "default": "mdi:thermometer-water" }, @@ -51,6 +54,14 @@ } }, "select": { + "bbw_dose_mode": { + "default": "mdi:all-inclusive-box", + "state": { + "continuous": "mdi:all-inclusive-box", + "dose1": "mdi:numeric-1-box", + "dose2": "mdi:numeric-2-box" + } + }, "prebrew_infusion_select": { "default": "mdi:water-pump-off", "state": { diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index abe454df3ad..1ea15e0072f 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.2.3"] + "requirements": ["pylamarzocco==2.2.4"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 675595bac4d..92431d33f90 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -5,9 +5,14 @@ from dataclasses import dataclass from typing import Any, cast from pylamarzocco import LaMarzoccoMachine -from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType +from pylamarzocco.const import DoseMode, ModelName, PreExtractionMode, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import CoffeeBoiler, PreBrewing, SteamBoilerTemperature +from pylamarzocco.models import ( + BrewByWeightDoses, + CoffeeBoiler, + PreBrewing, + SteamBoilerTemperature, +) from homeassistant.components.number import ( NumberDeviceClass, @@ -18,6 +23,7 @@ from homeassistant.const import ( PRECISION_TENTHS, PRECISION_WHOLE, EntityCategory, + UnitOfMass, UnitOfTemperature, UnitOfTime, ) @@ -219,6 +225,72 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( ) ), ), + LaMarzoccoNumberEntityDescription( + key="bbw_dose_1", + translation_key="bbw_dose", + translation_placeholders={"dose": "Dose 1"}, + device_class=NumberDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.GRAMS, + native_step=PRECISION_TENTHS, + native_min_value=5, + native_max_value=100, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_brew_by_weight_dose( + dose=DoseMode.DOSE_1, + value=value, + ) + ), + native_value_fn=( + lambda machine: cast( + BrewByWeightDoses, + machine.dashboard.config[WidgetType.CM_BREW_BY_WEIGHT_DOSES], + ).doses.dose_1.dose + ), + available_fn=lambda coordinator: ( + cast( + BrewByWeightDoses, + coordinator.device.dashboard.config[WidgetType.CM_BREW_BY_WEIGHT_DOSES], + ).scale_connected + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R) + ), + ), + LaMarzoccoNumberEntityDescription( + key="bbw_dose_2", + translation_key="bbw_dose", + translation_placeholders={"dose": "Dose 2"}, + device_class=NumberDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.GRAMS, + native_step=PRECISION_TENTHS, + native_min_value=5, + native_max_value=100, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_brew_by_weight_dose( + dose=DoseMode.DOSE_2, + value=value, + ) + ), + native_value_fn=( + lambda machine: cast( + BrewByWeightDoses, + machine.dashboard.config[WidgetType.CM_BREW_BY_WEIGHT_DOSES], + ).doses.dose_2.dose + ), + available_fn=lambda coordinator: ( + cast( + BrewByWeightDoses, + coordinator.device.dashboard.config[WidgetType.CM_BREW_BY_WEIGHT_DOSES], + ).scale_connected + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R) + ), + ), ) diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 053c3b75bef..d7662b6f50d 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from pylamarzocco.const import ( + DoseMode, ModelName, PreExtractionMode, SmartStandByType, @@ -13,7 +14,7 @@ from pylamarzocco.const import ( ) from pylamarzocco.devices import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import PreBrewing, SteamBoilerLevel +from pylamarzocco.models import BrewByWeightDoses, PreBrewing, SteamBoilerLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -50,6 +51,14 @@ STANDBY_MODE_HA_TO_LM = { STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()} +DOSE_MODE_HA_TO_LM = { + "continuous": DoseMode.CONTINUOUS, + "dose1": DoseMode.DOSE_1, + "dose2": DoseMode.DOSE_2, +} + +DOSE_MODE_LM_TO_HA = {value: key for key, value in DOSE_MODE_HA_TO_LM.items()} + @dataclass(frozen=True, kw_only=True) class LaMarzoccoSelectEntityDescription( @@ -117,6 +126,31 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( machine.schedule.smart_wake_up_sleep.smart_stand_by_after ], ), + LaMarzoccoSelectEntityDescription( + key="bbw_dose_mode", + translation_key="bbw_dose_mode", + entity_category=EntityCategory.CONFIG, + options=["continuous", "dose1", "dose2"], + select_option_fn=lambda machine, option: machine.set_brew_by_weight_dose_mode( + mode=DOSE_MODE_HA_TO_LM[option] + ), + current_option_fn=lambda machine: DOSE_MODE_LM_TO_HA[ + cast( + BrewByWeightDoses, + machine.dashboard.config[WidgetType.CM_BREW_BY_WEIGHT_DOSES], + ).mode + ], + available_fn=lambda coordinator: ( + cast( + BrewByWeightDoses, + coordinator.device.dashboard.config[WidgetType.CM_BREW_BY_WEIGHT_DOSES], + ).scale_connected + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R) + ), + ), ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 9b7194fe5f4..c39be40ddbb 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -87,6 +87,9 @@ } }, "number": { + "bbw_dose": { + "name": "Brew by weight {dose}" + }, "coffee_temp": { "name": "Coffee target temperature" }, @@ -107,6 +110,14 @@ } }, "select": { + "bbw_dose_mode": { + "name": "Brew by weight dose mode", + "state": { + "continuous": "Continuous", + "dose1": "Dose 1", + "dose2": "Dose 2" + } + }, "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", "state": { @@ -186,6 +197,9 @@ "bluetooth_connection_failed": { "message": "Error while connecting to machine via Bluetooth" }, + "bluetooth_required_offline": { + "message": "Bluetooth is required when offline mode is enabled, but no Bluetooth device was found" + }, "button_error": { "message": "Error while executing button {key}" }, @@ -212,12 +226,17 @@ } }, "options": { + "error": { + "bluetooth_required_offline": "Bluetooth is required when offline mode is enabled." + }, "step": { "init": { "data": { + "offline_mode": "Offline Mode", "use_bluetooth": "Use Bluetooth" }, "data_description": { + "offline_mode": "Enable offline mode to operate without internet connectivity through Bluetooth. Only local features will be available. Requires Bluetooth to be enabled.", "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?" } } diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 1bf77d7ab51..7555099aa52 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["ultraheat-api==0.5.7"] } diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 4315f4c5389..87a27e54462 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lastfm", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pylast"], "requirements": ["pylast==5.1.0"] diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index 99f03bcb5bb..dc37aecd836 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@xLarry"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["laundrify-aio==1.2.2"] } diff --git a/homeassistant/components/lawn_mower/trigger.py b/homeassistant/components/lawn_mower/trigger.py index e1dcada66ef..7bfcf0ea31e 100644 --- a/homeassistant/components/lawn_mower/trigger.py +++ b/homeassistant/components/lawn_mower/trigger.py @@ -1,15 +1,17 @@ """Provides triggers for lawn mowers.""" from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger from .const import DOMAIN, LawnMowerActivity TRIGGERS: dict[str, type[Trigger]] = { - "docked": make_entity_state_trigger(DOMAIN, LawnMowerActivity.DOCKED), - "errored": make_entity_state_trigger(DOMAIN, LawnMowerActivity.ERROR), - "paused_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.PAUSED), - "started_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.MOWING), + "docked": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.DOCKED), + "errored": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.ERROR), + "paused_mowing": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.PAUSED), + "started_mowing": make_entity_target_state_trigger( + DOMAIN, LawnMowerActivity.MOWING + ), } diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 5d04967631b..4f813ca4c00 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -19,8 +19,8 @@ from .const import CONF_DOMAIN_DATA from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=1) +PARALLEL_UPDATES = 2 +SCAN_INTERVAL = timedelta(minutes=10) def add_lcn_entities( diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 0874d916684..260c9bd3bf0 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -1,6 +1,5 @@ """Support for LCN climate control.""" -import asyncio from collections.abc import Iterable from datetime import timedelta from functools import partial @@ -37,7 +36,7 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 SCAN_INTERVAL = timedelta(minutes=1) @@ -172,14 +171,14 @@ class LcnClimate(LcnEntity, ClimateEntity): async def async_update(self) -> None: """Update the state of the entity.""" self._attr_available = any( - await asyncio.gather( - self.device_connection.request_status_variable( + [ + await self.device_connection.request_status_variable( self.variable, SCAN_INTERVAL.seconds ), - self.device_connection.request_status_variable( + await self.device_connection.request_status_variable( self.setpoint, SCAN_INTERVAL.seconds ), - ) + ] ) def input_received(self, input_obj: InputType) -> None: diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 7df79ef02b1..4066cef747f 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -1,6 +1,5 @@ """Support for LCN covers.""" -import asyncio from collections.abc import Coroutine, Iterable from datetime import timedelta from functools import partial @@ -28,7 +27,7 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 SCAN_INTERVAL = timedelta(minutes=1) @@ -134,14 +133,14 @@ class LcnOutputsCover(LcnEntity, CoverEntity): """Update the state of the entity.""" if not self.device_connection.is_group: self._attr_available = any( - await asyncio.gather( - self.device_connection.request_status_output( + [ + await self.device_connection.request_status_output( pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds ), - self.device_connection.request_status_output( + await self.device_connection.request_status_output( pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds ), - ) + ] ) def input_received(self, input_obj: InputType) -> None: @@ -274,7 +273,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): self.motor, self.positioning_mode, SCAN_INTERVAL.seconds ) ) - self._attr_available = any(await asyncio.gather(*coros)) + self._attr_available = any([await coro for coro in coros]) def input_received(self, input_obj: InputType) -> None: """Set cover states when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index f5b0f8732a5..be6ac6935cd 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -33,8 +33,8 @@ from .helpers import InputType, LcnConfigEntry BRIGHTNESS_SCALE = (1, 100) -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=1) +PARALLEL_UPDATES = 2 +SCAN_INTERVAL = timedelta(minutes=10) def add_lcn_entities( diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index a5c1c0f828d..0984f70475c 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -6,8 +6,9 @@ "config_flow": true, "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/lcn", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pypck"], "quality_scale": "silver", - "requirements": ["pypck==0.9.7", "lcn-frontend==0.2.7"] + "requirements": ["pypck==0.9.8", "lcn-frontend==0.2.7"] } diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 1d6839b5d91..e2089cda950 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -22,7 +22,7 @@ from .const import ( from .entity import LcnEntity from .helpers import LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 def add_lcn_entities( diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 667ac88f750..3515d6ab5f5 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -40,7 +40,7 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index d370d74d2dd..c18c92215a9 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -17,8 +17,8 @@ from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS, RELAY_PORTS, SET from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=1) +PARALLEL_UPDATES = 2 +SCAN_INTERVAL = timedelta(minutes=10) def add_lcn_switch_entities( diff --git a/homeassistant/components/leaone/manifest.json b/homeassistant/components/leaone/manifest.json index b7b9b5b1c38..4194343025b 100644 --- a/homeassistant/components/leaone/manifest.json +++ b/homeassistant/components/leaone/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/leaone", + "integration_type": "device", "iot_class": "local_push", "requirements": ["leaone-ble==0.3.0"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 74d375ff389..e64ef235a9f 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -34,6 +34,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.7"] } diff --git a/homeassistant/components/lektrico/binary_sensor.py b/homeassistant/components/lektrico/binary_sensor.py index 37e55ade798..6ada6e8e4ad 100644 --- a/homeassistant/components/lektrico/binary_sensor.py +++ b/homeassistant/components/lektrico/binary_sensor.py @@ -30,70 +30,70 @@ BINARY_SENSORS: tuple[LektricoBinarySensorEntityDescription, ...] = ( translation_key="state_e_activated", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["state_e_activated"]), + value_fn=lambda data: data["state_e_activated"], ), LektricoBinarySensorEntityDescription( key="overtemp", translation_key="overtemp", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["overtemp"]), + value_fn=lambda data: data["overtemp"], ), LektricoBinarySensorEntityDescription( key="critical_temp", translation_key="critical_temp", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["critical_temp"]), + value_fn=lambda data: data["critical_temp"], ), LektricoBinarySensorEntityDescription( key="overcurrent", translation_key="overcurrent", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["overcurrent"]), + value_fn=lambda data: data["overcurrent"], ), LektricoBinarySensorEntityDescription( key="meter_fault", translation_key="meter_fault", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["meter_fault"]), + value_fn=lambda data: data["meter_fault"], ), LektricoBinarySensorEntityDescription( key="undervoltage", translation_key="undervoltage", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["undervoltage_error"]), + value_fn=lambda data: data["undervoltage_error"], ), LektricoBinarySensorEntityDescription( key="overvoltage", translation_key="overvoltage", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["overvoltage_error"]), + value_fn=lambda data: data["overvoltage_error"], ), LektricoBinarySensorEntityDescription( key="rcd_error", translation_key="rcd_error", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["rcd_error"]), + value_fn=lambda data: data["rcd_error"], ), LektricoBinarySensorEntityDescription( key="cp_diode_failure", translation_key="cp_diode_failure", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["cp_diode_failure"]), + value_fn=lambda data: data["cp_diode_failure"], ), LektricoBinarySensorEntityDescription( key="contactor_failure", translation_key="contactor_failure", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda data: bool(data["contactor_failure"]), + value_fn=lambda data: data["contactor_failure"], ), ) diff --git a/homeassistant/components/lektrico/number.py b/homeassistant/components/lektrico/number.py index c54ee938607..0567aa4da84 100644 --- a/homeassistant/components/lektrico/number.py +++ b/homeassistant/components/lektrico/number.py @@ -38,7 +38,7 @@ NUMBERS: tuple[LektricoNumberEntityDescription, ...] = ( native_max_value=100, native_step=5, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: int(data["led_max_brightness"]), + value_fn=lambda data: data["led_max_brightness"], set_value_fn=lambda data, value: data.set_led_max_brightness(value), ), LektricoNumberEntityDescription( @@ -49,7 +49,7 @@ NUMBERS: tuple[LektricoNumberEntityDescription, ...] = ( native_max_value=32, native_step=1, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: int(data["dynamic_current"]), + value_fn=lambda data: data["dynamic_current"], set_value_fn=lambda data, value: data.set_dynamic_current(value), ), ) diff --git a/homeassistant/components/lektrico/sensor.py b/homeassistant/components/lektrico/sensor.py index 927011459b0..73e579569ca 100644 --- a/homeassistant/components/lektrico/sensor.py +++ b/homeassistant/components/lektrico/sensor.py @@ -79,7 +79,7 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = ( translation_key="charging_time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - value_fn=lambda data: int(data["charging_time"]), + value_fn=lambda data: data["charging_time"], ), LektricoSensorEntityDescription( key="power", @@ -87,20 +87,20 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, - value_fn=lambda data: float(data["instant_power"]), + value_fn=lambda data: data["instant_power"], ), LektricoSensorEntityDescription( key="energy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda data: float(data["session_energy"]) / 1000, + value_fn=lambda data: data["session_energy"] / 1000, ), LektricoSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: float(data["temperature"]), + value_fn=lambda data: data["temperature"], ), LektricoSensorEntityDescription( key="lifetime_energy", @@ -108,14 +108,14 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda data: int(data["total_charged_energy"]), + value_fn=lambda data: data["total_charged_energy"], ), LektricoSensorEntityDescription( key="installation_current", translation_key="installation_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: int(data["install_current"]), + value_fn=lambda data: data["install_current"], ), LektricoSensorEntityDescription( key="limit_reason", @@ -137,7 +137,7 @@ SENSORS_FOR_LB_DEVICES: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: int(data["breaker_curent"]), + value_fn=lambda data: data["breaker_curent"], ), ) @@ -146,14 +146,14 @@ SENSORS_FOR_1_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( key="voltage", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value_fn=lambda data: float(data["voltage_l1"]), + value_fn=lambda data: data["voltage_l1"], ), LektricoSensorEntityDescription( key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: float(data["current_l1"]), + value_fn=lambda data: data["current_l1"], ), ) @@ -163,21 +163,21 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( translation_key="voltage_l1", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value_fn=lambda data: float(data["voltage_l1"]), + value_fn=lambda data: data["voltage_l1"], ), LektricoSensorEntityDescription( key="voltage_l2", translation_key="voltage_l2", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value_fn=lambda data: float(data["voltage_l2"]), + value_fn=lambda data: data["voltage_l2"], ), LektricoSensorEntityDescription( key="voltage_l3", translation_key="voltage_l3", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value_fn=lambda data: float(data["voltage_l3"]), + value_fn=lambda data: data["voltage_l3"], ), LektricoSensorEntityDescription( key="current_l1", @@ -185,7 +185,7 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: float(data["current_l1"]), + value_fn=lambda data: data["current_l1"], ), LektricoSensorEntityDescription( key="current_l2", @@ -193,7 +193,7 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: float(data["current_l2"]), + value_fn=lambda data: data["current_l2"], ), LektricoSensorEntityDescription( key="current_l3", @@ -201,7 +201,7 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda data: float(data["current_l3"]), + value_fn=lambda data: data["current_l3"], ), ) @@ -213,14 +213,14 @@ SENSORS_FOR_LB_1_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, - value_fn=lambda data: float(data["power_l1"]), + value_fn=lambda data: data["power_l1"], ), LektricoSensorEntityDescription( key="pf", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: float(data["power_factor_l1"]) * 100, + value_fn=lambda data: data["power_factor_l1"] * 100, ), ) @@ -233,7 +233,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, - value_fn=lambda data: float(data["power_l1"]), + value_fn=lambda data: data["power_l1"], ), LektricoSensorEntityDescription( key="power_l2", @@ -242,7 +242,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, - value_fn=lambda data: float(data["power_l2"]), + value_fn=lambda data: data["power_l2"], ), LektricoSensorEntityDescription( key="power_l3", @@ -251,7 +251,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, - value_fn=lambda data: float(data["power_l3"]), + value_fn=lambda data: data["power_l3"], ), LektricoSensorEntityDescription( key="pf_l1", @@ -259,7 +259,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: float(data["power_factor_l1"]) * 100, + value_fn=lambda data: data["power_factor_l1"] * 100, ), LektricoSensorEntityDescription( key="pf_l2", @@ -267,7 +267,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: float(data["power_factor_l2"]) * 100, + value_fn=lambda data: data["power_factor_l2"] * 100, ), LektricoSensorEntityDescription( key="pf_l3", @@ -275,7 +275,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: float(data["power_factor_l3"]) * 100, + value_fn=lambda data: data["power_factor_l3"] * 100, ), ) diff --git a/homeassistant/components/lg_soundbar/manifest.json b/homeassistant/components/lg_soundbar/manifest.json index 287cfa65822..12d5c222460 100644 --- a/homeassistant/components/lg_soundbar/manifest.json +++ b/homeassistant/components/lg_soundbar/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lg_soundbar", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["temescal"], "requirements": ["temescal==0.5"] diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 67539cbee1e..4f84ef6fe2b 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from typing import Any @@ -241,6 +242,7 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): # If device is off, turn on first. if not self.data.is_on: await self.async_turn_on() + await asyncio.sleep(2) _LOGGER.debug( "[%s:%s] async_set_hvac_mode: %s", @@ -324,10 +326,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): # If device is off, turn on first. if not self.data.is_on: await self.async_turn_on() + await asyncio.sleep(2) if hvac_mode and hvac_mode != self.hvac_mode: await self.async_set_hvac_mode(HVACMode(hvac_mode)) - + await asyncio.sleep(2) _LOGGER.debug( "[%s:%s] async_set_temperature: %s", self.coordinator.device_name, diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 784ffb7092f..ffe9c07e541 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -3,8 +3,13 @@ "name": "LG ThinQ", "codeowners": ["@LG-ThinQ-Integration"], "config_flow": true, - "dhcp": [{ "macaddress": "34E6E6*" }], + "dhcp": [ + { + "macaddress": "34E6E6*" + } + ], "documentation": "https://www.home-assistant.io/integrations/lg_thinq", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["thinqconnect"], "requirements": ["thinqconnect==1.0.9"] diff --git a/homeassistant/components/libre_hardware_monitor/manifest.json b/homeassistant/components/libre_hardware_monitor/manifest.json index 183c72cb4cd..517fb0684ac 100644 --- a/homeassistant/components/libre_hardware_monitor/manifest.json +++ b/homeassistant/components/libre_hardware_monitor/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Sab44"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor", + "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", "requirements": ["librehardwaremonitor-api==1.5.0"] diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 76048c0a308..e9beb1d8cc7 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -29,7 +29,7 @@ from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.target import ( - TargetSelectorData, + TargetSelection, async_extract_referenced_entity_ids, ) @@ -272,7 +272,7 @@ class LIFXManager: async def service_handler(service: ServiceCall) -> None: """Apply a service, i.e. start an effect.""" referenced = async_extract_referenced_entity_ids( - self.hass, TargetSelectorData(service.data) + self.hass, TargetSelection(service.data) ) all_referenced = referenced.referenced | referenced.indirectly_referenced if all_referenced: diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index d7f50ca493b..b558d782707 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -49,6 +49,7 @@ "LIFX Z" ] }, + "integration_type": "device", "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ diff --git a/homeassistant/components/light/condition.py b/homeassistant/components/light/condition.py index 423b2df6b79..139f9e71ebc 100644 --- a/homeassistant/components/light/condition.py +++ b/homeassistant/components/light/condition.py @@ -1,7 +1,7 @@ """Provides conditions for lights.""" from collections.abc import Callable -from typing import TYPE_CHECKING, Any, Final, override +from typing import TYPE_CHECKING, Any, Final, Unpack, override import voluptuous as vol @@ -10,11 +10,11 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import config_validation as cv, target from homeassistant.helpers.condition import ( Condition, - ConditionCheckerType, + ConditionChecker, + ConditionCheckParams, ConditionConfig, - trace_condition_function, ) -from homeassistant.helpers.typing import ConfigType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -61,7 +61,7 @@ class StateConditionBase(Condition): self._state = state @override - async def async_get_checker(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Get the condition checker.""" def check_any_match_state(states: list[str]) -> bool: @@ -78,12 +78,11 @@ class StateConditionBase(Condition): elif self._behavior == BEHAVIOR_ALL: matcher = check_all_match_state - @trace_condition_function - def test_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool: """Test state condition.""" - selector_data = target.TargetSelectorData(self._target) + target_selection = target.TargetSelection(self._target) targeted_entities = target.async_extract_referenced_entity_ids( - hass, selector_data, expand_group=False + self._hass, target_selection, expand_group=False ) referenced_entity_ids = targeted_entities.referenced.union( targeted_entities.indirectly_referenced @@ -96,7 +95,7 @@ class StateConditionBase(Condition): light_entity_states = [ state.state for entity_id in light_entity_ids - if (state := hass.states.get(entity_id)) + if (state := self._hass.states.get(entity_id)) and state.state in STATE_CONDITION_VALID_STATES ] return matcher(light_entity_states) diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py index ea85b2eda80..3ba7976c71a 100644 --- a/homeassistant/components/light/trigger.py +++ b/homeassistant/components/light/trigger.py @@ -2,13 +2,13 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger from .const import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { - "turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF), - "turned_on": make_entity_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 46ffad162f3..f78e84003ca 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@StefanIacobLivisi", "@planbnet"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", + "integration_type": "hub", "iot_class": "local_polling", "requirements": ["livisi==0.0.25"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 3676117f488..8447babb737 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==11.1.0"] + "requirements": ["ical==12.1.2"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 6278ffd9a61..3980f0e5c64 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==11.1.0"] + "requirements": ["ical==12.1.2"] } diff --git a/homeassistant/components/lookin/manifest.json b/homeassistant/components/lookin/manifest.json index 63da470c5cd..368e44805ac 100644 --- a/homeassistant/components/lookin/manifest.json +++ b/homeassistant/components/lookin/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ANMalko", "@bdraco"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lookin", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiolookin"], "requirements": ["aiolookin==1.0.0"], diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 473222fdcf3..59b66d1a6df 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -6,6 +6,7 @@ "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/loqed", + "integration_type": "device", "iot_class": "local_push", "requirements": ["loqedAPI==2.1.10"], "zeroconf": [ diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 630ca71410e..172bf2492f3 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@majuss", "@suaveolent"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lupusec", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["lupupy"], "requirements": ["lupupy==0.3.2"] diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 8d3da47795a..5351573c6e4 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@cdheiser", "@wilburCForce"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], "requirements": ["pylutron==0.2.18"], diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 4ca6d7c59fa..c54643ea07b 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["Smart Bridge"] }, + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylutron_caseta"], "requirements": ["pylutron-caseta==0.26.0"], diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json index bc6b34ee970..62e5683722f 100644 --- a/homeassistant/components/lyric/manifest.json +++ b/homeassistant/components/lyric/manifest.json @@ -19,6 +19,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/lyric", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aiolyric"], "requirements": ["aiolyric==2.0.2"] diff --git a/homeassistant/components/mailgun/manifest.json b/homeassistant/components/mailgun/manifest.json index 9cd430008ae..2f1abee9206 100644 --- a/homeassistant/components/mailgun/manifest.json +++ b/homeassistant/components/mailgun/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/mailgun", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["pymailgunner"], "requirements": ["pymailgunner==1.4"] diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 09a81fe9a47..a3148f3719f 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -499,4 +499,53 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.WindowCovering.Attributes.ConfigStatus,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="ThermostatRemoteSensing_LocalTemperature", + translation_key="thermostat_remote_sensing_local_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + # LocalTemperature bit from RemoteSensing attribute + device_to_ha=lambda x: bool( + x + & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kLocalTemperature # Calculated Local Temperature is derived from a remote node + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.Thermostat.Attributes.RemoteSensing,), + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="ThermostatRemoteSensing_OutdoorTemperature", + translation_key="thermostat_remote_sensing_outdoor_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + # OutdoorTemperature bit from RemoteSensing attribute + device_to_ha=lambda x: bool( + x + & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kOutdoorTemperature # OutdoorTemperature is derived from a remote node + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.Thermostat.Attributes.RemoteSensing,), + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="ThermostatRemoteSensing_Occupancy", + translation_key="thermostat_remote_sensing_occupancy", + entity_category=EntityCategory.DIAGNOSTIC, + # Occupancy bit from RemoteSensing attribute + device_to_ha=lambda x: bool( + x + & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kOccupancy # Occupancy is derived from a remote node + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.Thermostat.Attributes.RemoteSensing,), + featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kOccupancy, + allow_multi=True, + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 0e230e97da5..ec96875c06b 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -42,6 +42,9 @@ "number": { "cook_time": { "default": "mdi:microwave" + }, + "speaker_setpoint": { + "default": "mdi:speaker" } }, "select": { diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 625187e9b6c..d353d117074 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/matter", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["python-matter-server==8.1.0"], + "requirements": ["python-matter-server==8.1.2"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 733779e19f8..a20e6b3b42a 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -365,6 +365,31 @@ DISCOVERY_SCHEMAS = [ clusters.MicrowaveOvenControl.Attributes.MaxCookTime, ), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="speaker_setpoint", + translation_key="speaker_setpoint", + native_unit_of_measurement=PERCENTAGE, + command=lambda value: clusters.LevelControl.Commands.MoveToLevel( + level=int(value) + ), + native_min_value=0, + native_max_value=100, + native_step=1, + device_to_ha=lambda x: None if x is None else x, + min_attribute=clusters.LevelControl.Attributes.MinLevel, + max_attribute=clusters.LevelControl.Attributes.MaxLevel, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.LevelControl.Attributes.CurrentLevel, + clusters.LevelControl.Attributes.MinLevel, + clusters.LevelControl.Attributes.MaxLevel, + ), + device_type=(device_types.Speaker,), + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index a441160b02a..cc7041965fe 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -183,6 +183,48 @@ class MatterModeSelectEntity(MatterAttributeSelectEntity): self._attr_name = desc +class MatterDoorLockOperatingModeSelectEntity(MatterAttributeSelectEntity): + """Representation of a Door Lock Operating Mode select entity. + + This entity dynamically filters available operating modes based on the device's + `SupportedOperatingModes` bitmap attribute. In this bitmap, bit=0 indicates a + supported mode and bit=1 indicates unsupported (inverted from typical bitmap conventions). + If the bitmap is unavailable, only mandatory modes are included. The mapping from + bitmap bits to operating mode values is defined by the Matter specification. + """ + + entity_description: MatterMapSelectEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # Get the bitmap of supported operating modes + supported_modes_bitmap = self.get_matter_attribute_value( + self.entity_description.list_attribute + ) + + # Convert bitmap to list of supported mode values + # NOTE: The Matter spec inverts the usual meaning: bit=0 means supported, + # bit=1 means not supported, undefined bits must be 1. Mandatory modes are + # bits 0 (Normal) and 3 (NoRemoteLockUnlock). + num_mode_bits = supported_modes_bitmap.bit_length() + supported_mode_values = [ + bit_position + for bit_position in range(num_mode_bits) + if not supported_modes_bitmap & (1 << bit_position) + ] + + # Map supported mode values to their string representations + self._attr_options = [ + mapped_value + for mode_value in supported_mode_values + if (mapped_value := self.entity_description.device_to_ha(mode_value)) + ] + + # Use base implementation to set the current option + super()._update_from_device() + + class MatterListSelectEntity(MatterEntity, SelectEntity): """Representation of a select entity from Matter list and selected item Cluster attribute(s).""" @@ -594,15 +636,17 @@ DISCOVERY_SCHEMAS = [ ), MatterDiscoverySchema( platform=Platform.SELECT, - entity_description=MatterSelectEntityDescription( + entity_description=MatterMapSelectEntityDescription( key="DoorLockOperatingMode", - entity_category=EntityCategory.CONFIG, translation_key="door_lock_operating_mode", - options=list(DOOR_LOCK_OPERATING_MODE_MAP.values()), + list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes, device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get, ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get, ), - entity_class=MatterAttributeSelectEntity, - required_attributes=(clusters.DoorLock.Attributes.OperatingMode,), + entity_class=MatterDoorLockOperatingModeSelectEntity, + required_attributes=( + clusters.DoorLock.Attributes.OperatingMode, + clusters.DoorLock.Attributes.SupportedOperatingModes, + ), ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index fd180cc8e45..0832675744e 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -183,10 +183,35 @@ PUMP_CONTROL_MODE_MAP = { clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, } +MATTER_2000_TO_UNIX_EPOCH_OFFSET = ( + 946684800 # Seconds from Matter 2000 epoch to Unix epoch +) HUMIDITY_SCALING_FACTOR = 100 TEMPERATURE_SCALING_FACTOR = 100 +def matter_epoch_seconds_to_utc(x: int | None) -> datetime | None: + """Convert Matter epoch seconds (since 2000-01-01) to UTC datetime. + + Returns None for non-positive or None values (represents unknown/absent). + """ + if x is None or x <= 0: + return None + return dt_util.utc_from_timestamp(x + MATTER_2000_TO_UNIX_EPOCH_OFFSET) + + +def matter_epoch_microseconds_to_utc(x: int | None) -> datetime | None: + """Convert Matter epoch microseconds (since 2000-01-01) to UTC datetime. + + The value is in microseconds; convert to seconds before applying offset. + Returns None for non-positive or None values. + """ + if x is None or x <= 0: + return None + seconds = x // 1_000_000 + return dt_util.utc_from_timestamp(seconds + MATTER_2000_TO_UNIX_EPOCH_OFFSET) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -1468,7 +1493,8 @@ DISCOVERY_SCHEMAS = [ translation_key="auto_close_time", device_class=SensorDeviceClass.TIMESTAMP, state_class=None, - device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None), + # AutoCloseTime is defined as epoch-us in the spec + device_to_ha=matter_epoch_microseconds_to_utc, ), entity_class=MatterSensor, featuremap_contains=clusters.ValveConfigurationAndControl.Bitmaps.Feature.kTimeSync, @@ -1483,7 +1509,8 @@ DISCOVERY_SCHEMAS = [ translation_key="estimated_end_time", device_class=SensorDeviceClass.TIMESTAMP, state_class=None, - device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None), + # EstimatedEndTime is defined as epoch-s (Matter 2000 epoch) in the spec + device_to_ha=matter_epoch_seconds_to_utc, ), entity_class=MatterSensor, required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,), diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index d7cf0553c22..9fa3425c4e4 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -89,6 +89,15 @@ "test_in_progress": { "name": "Test in progress" }, + "thermostat_remote_sensing_local_temperature": { + "name": "Local temperature remote sensing" + }, + "thermostat_remote_sensing_occupancy": { + "name": "Occupancy remote sensing" + }, + "thermostat_remote_sensing_outdoor_temperature": { + "name": "Outdoor temperature remote sensing" + }, "valve_fault_blocked": { "name": "Valve blocked" }, @@ -223,6 +232,9 @@ "pump_setpoint": { "name": "Setpoint" }, + "speaker_setpoint": { + "name": "Volume" + }, "temperature_offset": { "name": "Temperature offset" }, diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index e5ee1bc9e99..d043ecbf539 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -94,7 +94,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo await statistics_coordinator.async_config_entry_first_refresh() entry.runtime_data = MealieData( - client, mealplan_coordinator, shoppinglist_coordinator, statistics_coordinator + client, + version, + mealplan_coordinator, + shoppinglist_coordinator, + statistics_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py index 556ddede2e2..9831bb8105a 100644 --- a/homeassistant/components/mealie/calendar.py +++ b/homeassistant/components/mealie/calendar.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from aiomealie import Mealplan, MealplanEntryType +from awesomeversion import AwesomeVersion from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -23,10 +24,24 @@ async def async_setup_entry( ) -> None: """Set up the calendar platform for entity.""" coordinator = entry.runtime_data.mealplan_coordinator + version = entry.runtime_data.version + + supported_mealplan_entry_types: list[MealplanEntryType] + if version.valid and version < AwesomeVersion("v3.7.0"): + # Prior to Mealie 3.7.0, only these mealplan entry types were supported + supported_mealplan_entry_types = [ + MealplanEntryType.BREAKFAST, + MealplanEntryType.DINNER, + MealplanEntryType.LUNCH, + MealplanEntryType.SIDE, + ] + else: + # For Mealie 3.7.0 and newer and nightlies, add all current mealplan entry types + supported_mealplan_entry_types = list(MealplanEntryType) async_add_entities( MealieMealplanCalendarEntity(coordinator, entry_type) - for entry_type in MealplanEntryType + for entry_type in supported_mealplan_entry_types ) diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index ae5b9cd8c97..b7e49fe324e 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -16,6 +16,7 @@ from aiomealie import ( ShoppingList, Statistics, ) +from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -33,6 +34,7 @@ class MealieData: """Mealie data type.""" client: MealieClient + version: AwesomeVersion mealplan_coordinator: MealieMealplanCoordinator shoppinglist_coordinator: MealieShoppingListCoordinator statistics_coordinator: MealieStatisticsCoordinator diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 8a561c9c0b5..5e090a6af73 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.1.0"] + "requirements": ["aiomealie==1.1.1"] } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 37b485e18f2..cdee30950c4 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -10,6 +10,7 @@ from aiomealie import ( MealieValidationError, MealplanEntryType, ) +from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -127,6 +128,27 @@ def _async_get_entry(call: ServiceCall) -> MealieConfigEntry: return cast(MealieConfigEntry, entry) +def _validate_mealplan_type(version: AwesomeVersion, entry_type: str) -> None: + """Validate mealplan entry type, if prior to 3.7.0.""" + + if ( + version.valid + and version < AwesomeVersion("v3.7.0") + and entry_type + not in { + MealplanEntryType.BREAKFAST.value, + MealplanEntryType.DINNER.value, + MealplanEntryType.LUNCH.value, + MealplanEntryType.SIDE.value, + } + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_mealplan_entry_type", + translation_placeholders={"mealplan_type": entry_type}, + ) + + async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse: """Get the mealplan for a specific range.""" entry = _async_get_entry(call) @@ -219,6 +241,9 @@ async def _async_set_random_mealplan(call: ServiceCall) -> ServiceResponse: mealplan_date = call.data[ATTR_DATE] entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) client = entry.runtime_data.client + + _validate_mealplan_type(entry.runtime_data.version, entry_type.value) + try: mealplan = await client.random_mealplan(mealplan_date, entry_type) except MealieConnectionError as err: @@ -237,6 +262,9 @@ async def _async_set_mealplan(call: ServiceCall) -> ServiceResponse: mealplan_date = call.data[ATTR_DATE] entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) client = entry.runtime_data.client + + _validate_mealplan_type(entry.runtime_data.version, entry_type.value) + try: mealplan = await client.set_mealplan( mealplan_date, diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 6a78564a578..31181c0d091 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -78,6 +78,9 @@ set_random_mealplan: - lunch - dinner - side + - dessert + - snack + - drink translation_key: mealplan_entry_type set_mealplan: @@ -98,6 +101,9 @@ set_mealplan: - lunch - dinner - side + - dessert + - snack + - drink translation_key: mealplan_entry_type recipe_id: selector: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 653414d9132..a9a636f2892 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -71,14 +71,23 @@ "breakfast": { "name": "Breakfast" }, + "dessert": { + "name": "Dessert" + }, "dinner": { "name": "Dinner" }, + "drink": { + "name": "Drink" + }, "lunch": { "name": "Lunch" }, "side": { "name": "Side" + }, + "snack": { + "name": "Snack" } }, "sensor": { @@ -126,6 +135,9 @@ "integration_not_found": { "message": "Integration \"{target}\" not found in registry." }, + "invalid_mealplan_entry_type": { + "message": "Entry type {mealplan_type} is not valid for this Mealie version." + }, "item_not_found_error": { "message": "Item {shopping_list_item} not found." }, @@ -161,9 +173,12 @@ "mealplan_entry_type": { "options": { "breakfast": "[%key:component::mealie::entity::calendar::breakfast::name%]", + "dessert": "[%key:component::mealie::entity::calendar::dessert::name%]", "dinner": "[%key:component::mealie::entity::calendar::dinner::name%]", + "drink": "[%key:component::mealie::entity::calendar::drink::name%]", "lunch": "[%key:component::mealie::entity::calendar::lunch::name%]", - "side": "[%key:component::mealie::entity::calendar::side::name%]" + "side": "[%key:component::mealie::entity::calendar::side::name%]", + "snack": "[%key:component::mealie::entity::calendar::snack::name%]" } } }, diff --git a/homeassistant/components/meater/manifest.json b/homeassistant/components/meater/manifest.json index 1e10d60d8c9..813edab26f8 100644 --- a/homeassistant/components/meater/manifest.json +++ b/homeassistant/components/meater/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Sotolotl", "@emontnemery"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meater", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["meater-python==0.0.8"] } diff --git a/homeassistant/components/medcom_ble/manifest.json b/homeassistant/components/medcom_ble/manifest.json index 4aacae4647d..789ceb98b24 100644 --- a/homeassistant/components/medcom_ble/manifest.json +++ b/homeassistant/components/medcom_ble/manifest.json @@ -10,6 +10,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/medcom_ble", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["medcom-ble==0.1.1"] } diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 0313be56286..a39ccfa9ced 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -1,13 +1,13 @@ """Provides triggers for media players.""" from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_conditional_entity_state_trigger +from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger from . import MediaPlayerState from .const import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { - "stopped_playing": make_conditional_entity_state_trigger( + "stopped_playing": make_entity_transition_trigger( DOMAIN, from_states={ MediaPlayerState.BUFFERING, diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 45dce207f7e..35356591836 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -11,6 +11,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["melnor-bluetooth==0.0.25"] } diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 7b913df4d3c..3f8cc4b10f2 100644 --- a/homeassistant/components/met_eireann/manifest.json +++ b/homeassistant/components/met_eireann/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@DylanGore"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met_eireann", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["meteireann"], "requirements": ["PyMetEireann==2024.11.0"] diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index d82d0c3f91b..ab58f352501 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], "requirements": ["meteofrance-api==1.4.0"] diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 7333f7b0c19..975fb038650 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -62,7 +62,7 @@ SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = ( key="pressure", name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, - device_class=SensorDeviceClass.PRESSURE, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, data_path="current_forecast:sea_level", diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 4a18a340ff2..30e4318e0b4 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -98,50 +98,28 @@ DEVICE_TYPE_TAGS = { } -class StateStatus(IntEnum): +class StateStatus(MieleEnum, missing_to_none=True): """Define appliance states.""" - RESERVED = 0 - OFF = 1 - ON = 2 - PROGRAMMED = 3 - WAITING_TO_START = 4 - IN_USE = 5 - PAUSE = 6 - PROGRAM_ENDED = 7 - FAILURE = 8 - PROGRAM_INTERRUPTED = 9 - IDLE = 10 - RINSE_HOLD = 11 - SERVICE = 12 - SUPERFREEZING = 13 - SUPERCOOLING = 14 - SUPERHEATING = 15 - SUPERCOOLING_SUPERFREEZING = 146 - AUTOCLEANING = 147 - NOT_CONNECTED = 255 - - -STATE_STATUS_TAGS = { - StateStatus.OFF: "off", - StateStatus.ON: "on", - StateStatus.PROGRAMMED: "programmed", - StateStatus.WAITING_TO_START: "waiting_to_start", - StateStatus.IN_USE: "in_use", - StateStatus.PAUSE: "pause", - StateStatus.PROGRAM_ENDED: "program_ended", - StateStatus.FAILURE: "failure", - StateStatus.PROGRAM_INTERRUPTED: "program_interrupted", - StateStatus.IDLE: "idle", - StateStatus.RINSE_HOLD: "rinse_hold", - StateStatus.SERVICE: "service", - StateStatus.SUPERFREEZING: "superfreezing", - StateStatus.SUPERCOOLING: "supercooling", - StateStatus.SUPERHEATING: "superheating", - StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing", - StateStatus.AUTOCLEANING: "autocleaning", - StateStatus.NOT_CONNECTED: "not_connected", -} + reserved = 0 + off = 1 + on = 2 + programmed = 3 + waiting_to_start = 4 + in_use = 5 + pause = 6 + program_ended = 7 + failure = 8 + program_interrupted = 9 + idle = 10 + rinse_hold = 11 + service = 12 + superfreezing = 13 + supercooling = 14 + superheating = 15 + supercooling_superfreezing = 146 + autocleaning = 147 + not_connected = 255 class MieleActions(IntEnum): diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index ff2207fd0aa..93e109d3500 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -73,5 +73,5 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): return ( super().available and self._device_id in self.coordinator.data.devices - and (self.device.state_status is not StateStatus.NOT_CONNECTED) + and (self.device.state_status is not StateStatus.not_connected) ) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 4d51beba4d8..6ed7940c807 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -38,7 +38,6 @@ from .const import ( DOMAIN, PROGRAM_IDS, PROGRAM_PHASE, - STATE_STATUS_TAGS, MieleAppliance, PlatePowerStep, StateDryingStep, @@ -195,7 +194,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_key="status", value_fn=lambda value: value.state_status, device_class=SensorDeviceClass.ENUM, - options=sorted(set(STATE_STATUS_TAGS.values())), + options=sorted(set(StateStatus.keys())), ), ), MieleSensorDefinition( @@ -930,7 +929,7 @@ class MieleStatusSensor(MieleSensor): @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status)) + return StateStatus(self.device.state_status).name @property def available(self) -> bool: @@ -998,11 +997,11 @@ class MieleTimeSensor(MieleRestorableSensor): """Update the last value of the sensor.""" current_value = self.entity_description.value_fn(self.device) - current_status = StateStatus(self.device.state_status) + current_status = StateStatus(self.device.state_status).name # report end-specific value when program ends (some devices are immediately reporting 0...) if ( - current_status == StateStatus.PROGRAM_ENDED + current_status == StateStatus.program_ended.name and self.entity_description.end_value_fn is not None ): self._attr_native_value = self.entity_description.end_value_fn( @@ -1010,11 +1009,15 @@ class MieleTimeSensor(MieleRestorableSensor): ) # keep value when program ends if no function is specified - elif current_status == StateStatus.PROGRAM_ENDED: + elif current_status == StateStatus.program_ended.name: pass # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) - elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE): + elif current_status in ( + StateStatus.off.name, + StateStatus.on.name, + StateStatus.idle.name, + ): self._attr_native_value = None # otherwise, cache value and return it @@ -1030,7 +1033,7 @@ class MieleAbsoluteTimeSensor(MieleRestorableSensor): def _update_native_value(self) -> None: """Update the last value of the sensor.""" current_value = self.entity_description.value_fn(self.device) - current_status = StateStatus(self.device.state_status) + current_status = StateStatus(self.device.state_status).name # The API reports with minute precision, to avoid changing # the value too often, we keep the cached value if it differs @@ -1043,11 +1046,15 @@ class MieleAbsoluteTimeSensor(MieleRestorableSensor): < current_value < self._previous_value + timedelta(seconds=90) ) - ) or current_status == StateStatus.PROGRAM_ENDED: + ) or current_status == StateStatus.program_ended.name: return # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) - if current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE): + if current_status in ( + StateStatus.off.name, + StateStatus.on.name, + StateStatus.idle.name, + ): self._attr_native_value = None # otherwise, cache value and return it @@ -1064,7 +1071,7 @@ class MieleConsumptionSensor(MieleRestorableSensor): def _update_native_value(self) -> None: """Update the last value of the sensor.""" current_value = self.entity_description.value_fn(self.device) - current_status = StateStatus(self.device.state_status) + current_status = StateStatus(self.device.state_status).name # Guard for corrupt restored value restored_value = ( self._attr_native_value @@ -1079,12 +1086,12 @@ class MieleConsumptionSensor(MieleRestorableSensor): # Force unknown when appliance is not able to report consumption if current_status in ( - StateStatus.ON, - StateStatus.OFF, - StateStatus.PROGRAMMED, - StateStatus.WAITING_TO_START, - StateStatus.IDLE, - StateStatus.SERVICE, + StateStatus.on.name, + StateStatus.off.name, + StateStatus.programmed.name, + StateStatus.waiting_to_start.name, + StateStatus.idle.name, + StateStatus.service.name, ): self._is_reporting = False self._attr_native_value = None @@ -1093,7 +1100,7 @@ class MieleConsumptionSensor(MieleRestorableSensor): # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless # we already saw a valid value in this cycle from cache elif ( - current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + current_status in (StateStatus.in_use.name, StateStatus.pause.name) and not self._is_reporting and last_value > 0 ): @@ -1101,7 +1108,7 @@ class MieleConsumptionSensor(MieleRestorableSensor): self._is_reporting = True elif ( - current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + current_status in (StateStatus.in_use.name, StateStatus.pause.name) and not self._is_reporting and current_value is not None and cast(int, current_value) > 0 @@ -1109,7 +1116,7 @@ class MieleConsumptionSensor(MieleRestorableSensor): self._attr_native_value = 0 # keep value when program ends - elif current_status == StateStatus.PROGRAM_ENDED: + elif current_status == StateStatus.program_ended.name: pass else: diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 6d55ba52840..dcebedd60f0 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1061,6 +1061,7 @@ "program_ended": "Program ended", "program_interrupted": "Program interrupted", "programmed": "Programmed", + "reserved": "Reserved", "rinse_hold": "Rinse hold", "service": "Service", "supercooling": "Supercooling", diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py index 277cf56e639..f44e8d74deb 100644 --- a/homeassistant/components/miele/switch.py +++ b/homeassistant/components/miele/switch.py @@ -58,7 +58,7 @@ SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = ( description=MieleSwitchDescription( key="supercooling", value_fn=lambda value: value.state_status, - on_value=StateStatus.SUPERCOOLING, + on_value=StateStatus.supercooling, translation_key="supercooling", on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL}, off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL}, @@ -73,7 +73,7 @@ SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = ( description=MieleSwitchDescription( key="superfreezing", value_fn=lambda value: value.state_status, - on_value=StateStatus.SUPERFREEZING, + on_value=StateStatus.superfreezing, translation_key="superfreezing", on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE}, off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE}, diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index ba496923a30..a9a920e3f52 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -160,7 +160,7 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit """Representation of a Mill Thermostat device.""" _attr_has_entity_name = True - _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO, HVACMode.OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None @@ -205,6 +205,9 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit elif hvac_mode == HVACMode.OFF: await self.coordinator.mill_data_connection.set_operation_mode_off() await self.coordinator.async_request_refresh() + elif hvac_mode == HVACMode.AUTO: + await self.coordinator.mill_data_connection.set_operation_mode_weekly_program() + await self.coordinator.async_request_refresh() @callback def _handle_coordinator_update(self) -> None: @@ -218,12 +221,19 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit self._attr_target_temperature = data["set_temperature"] self._attr_current_temperature = data["ambient_temperature"] - if data["operation_mode"] == OperationMode.OFF.value: + operation_mode = data["operation_mode"] + is_heating = data["current_power"] > 0 + + if operation_mode == OperationMode.OFF.value: self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF + elif operation_mode == OperationMode.WEEKLY_PROGRAM.value: + self._attr_hvac_mode = HVACMode.AUTO + self._attr_hvac_action = ( + HVACAction.HEATING if is_heating else HVACAction.IDLE + ) else: self._attr_hvac_mode = HVACMode.HEAT - if data["current_power"] > 0: - self._attr_hvac_action = HVACAction.HEATING - else: - self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_action = ( + HVACAction.HEATING if is_heating else HVACAction.IDLE + ) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 6d33c18b3b4..def7ad42b4a 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.14.1", "mill-local==0.3.0"] + "requirements": ["millheater==0.14.1", "mill-local==0.5.0"] } diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 62906ea65ae..206e25433e2 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -315,7 +315,7 @@ class MoldIndicator(SensorEntity): # Return an error if the sensor change its state to Unknown. if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - _LOGGER.error( + _LOGGER.debug( "Unable to parse temperature sensor %s with state: %s", state.entity_id, state.state, @@ -352,7 +352,7 @@ class MoldIndicator(SensorEntity): # Return an error if the sensor change its state to Unknown. if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - _LOGGER.error( + _LOGGER.debug( "Unable to parse humidity sensor %s, state: %s", state.entity_id, state.state, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 16c396c0bb6..b894bae7db5 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -46,7 +46,7 @@ "ws_path": "WebSocket path" }, "data_description": { - "advanced_options": "Enable and select **Next** to set advanced options.", + "advanced_options": "Enable and select **Submit** to set advanced options.", "broker": "The hostname or IP address of your MQTT broker.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_cert": "The client certificate to authenticate against your MQTT broker.", diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index dd9e64a92d1..c0d56abba2b 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -27,7 +27,11 @@ from music_assistant_models.player import Player from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import ( @@ -101,6 +105,15 @@ async def async_setup_entry( # noqa: C901 ) raise ConfigEntryNotReady(f"Invalid server version: {err}") from err except (AuthenticationRequired, AuthenticationFailed, InvalidToken) as err: + assert mass.server_info is not None + # Users cannot reauthenticate when running as Home Assistant addon, + # so raising ConfigEntryAuthFailed in that case would be incorrect. + # Instead we should wait until the addon discovery is completed, + # as that will set up authentication and reload the entry automatically. + if mass.server_info.homeassistant_addon: + raise ConfigEntryError( + "Authentication failed, addon discovery not completed yet" + ) from err raise ConfigEntryAuthFailed( f"Authentication failed for {mass_url}: {err}" ) from err diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index 226a4dda28f..c03ae85fd04 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -179,6 +179,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): ConfigEntryState.LOADED, ConfigEntryState.SETUP_ERROR, ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, ): self.hass.config_entries.async_schedule_reload(entry.entry_id) diff --git a/homeassistant/components/nederlandse_spoorwegen/diagnostics.py b/homeassistant/components/nederlandse_spoorwegen/diagnostics.py new file mode 100644 index 00000000000..95735849922 --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/diagnostics.py @@ -0,0 +1,122 @@ +"""Diagnostics support for Nederlandse Spoorwegen.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN +from .coordinator import NSConfigEntry + +TO_REDACT = [ + CONF_API_KEY, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NSConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators_data = {} + + # Collect data from all coordinators + for subentry_id, coordinator in entry.runtime_data.items(): + coordinators_data[subentry_id] = { + "coordinator_info": { + "name": coordinator.name, + "departure": coordinator.departure, + "destination": coordinator.destination, + "via": coordinator.via, + "departure_time": coordinator.departure_time, + }, + "route_data": { + "trips_count": len(coordinator.data.trips) if coordinator.data else 0, + "has_first_trip": coordinator.data.first_trip is not None + if coordinator.data + else False, + "has_next_trip": coordinator.data.next_trip is not None + if coordinator.data + else False, + } + if coordinator.data + else None, + } + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "coordinators": coordinators_data, + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: NSConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a route.""" + # Find the coordinator for this device + coordinator = None + subentry_id = None + + # Each device has an identifier (DOMAIN, subentry_id) + for identifier in device.identifiers: + if identifier[0] == DOMAIN: + subentry_id = identifier[1] + coordinator = entry.runtime_data.get(subentry_id) + break + + # Collect detailed diagnostics for this specific route + device_data = { + "device_info": { + "subentry_id": subentry_id, + "device_name": device.name, + "manufacturer": device.manufacturer, + "model": device.model, + }, + "coordinator_info": { + "name": coordinator.name, + "departure": coordinator.departure, + "destination": coordinator.destination, + "via": coordinator.via, + "departure_time": coordinator.departure_time, + } + if coordinator + else None, + } + + # Add detailed trip data if available + if coordinator and coordinator.data: + device_data["trip_details"] = { + "trips_count": len(coordinator.data.trips), + "has_first_trip": coordinator.data.first_trip is not None, + "has_next_trip": coordinator.data.next_trip is not None, + } + + # Add first trip details if available + if coordinator.data.first_trip: + first_trip = coordinator.data.first_trip + device_data["first_trip"] = { + "departure_time_planned": str(first_trip.departure_time_planned) + if first_trip.departure_time_planned + else None, + "departure_time_actual": str(first_trip.departure_time_actual) + if first_trip.departure_time_actual + else None, + "arrival_time_planned": str(first_trip.arrival_time_planned) + if first_trip.arrival_time_planned + else None, + "arrival_time_actual": str(first_trip.arrival_time_actual) + if first_trip.arrival_time_actual + else None, + "departure_platform_planned": first_trip.departure_platform_planned, + "departure_platform_actual": first_trip.departure_platform_actual, + "arrival_platform_planned": first_trip.arrival_platform_planned, + "arrival_platform_actual": first_trip.arrival_platform_actual, + "status": str(first_trip.status) if first_trip.status else None, + "nr_transfers": first_trip.nr_transfers, + "going": first_trip.going, + } + + return device_data diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 1262ff190c9..794b481618e 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -4,13 +4,13 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio -from collections.abc import Awaitable, Callable from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device +from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventMessage from google_nest_sdm.event_media import Media from google_nest_sdm.exceptions import ( @@ -71,7 +71,7 @@ from .media_source import ( async_get_media_source_devices, async_get_transcoder, ) -from .types import NestConfigEntry, NestData +from .types import DevicesAddedListener, NestConfigEntry, NestData _LOGGER = logging.getLogger(__name__) @@ -124,19 +124,17 @@ class SignalUpdateCallback: def __init__( self, hass: HomeAssistant, - config_reload_cb: Callable[[], Awaitable[None]], config_entry: NestConfigEntry, ) -> None: """Initialize EventCallback.""" self._hass = hass - self._config_reload_cb = config_reload_cb self._config_entry = config_entry + self._device_listeners: list[DevicesAddedListener] = [] + self._known_devices: dict[str, Device] = {} + self._device_manager: DeviceManager | None = None async def async_handle_event(self, event_message: EventMessage) -> None: """Process an incoming EventMessage.""" - if event_message.relation_update: - _LOGGER.info("Devices or homes have changed; Need reload to take effect") - return if not event_message.resource_update_name: return device_id = event_message.resource_update_name @@ -187,6 +185,59 @@ class SignalUpdateCallback: return [] return list(device.traits) + def set_device_manager(self, device_manager: DeviceManager) -> None: + """Set the device manager and register for device changes.""" + self._device_manager = device_manager + device_manager.set_change_callback(self._devices_updated_cb) + self._update_devices(self._device_manager.devices) + + async def _devices_updated_cb(self) -> None: + """Handle callback when devices are updated.""" + _LOGGER.debug("Devices updated callback invoked") + if self._device_manager is None: + _LOGGER.debug("No device manager available") + return + self._update_devices(self._device_manager.devices) + + def register_devices_listener(self, listener: DevicesAddedListener) -> None: + """Add a listener for device changes.""" + self._device_listeners.append(listener) + # Immediately notify about existing devices + listener(list(self._known_devices.values())) + + def _update_devices(self, devices: dict[str, Device]) -> None: + """Update the set of devices and notify listeners of changes. + + This is invoked when the set of devices changes with the entire set of + devices, and will notify listeners about any newly added devices and + remove devices from the device registry that are no longer present. + """ + added_devices = [] + for device_id, device in devices.items(): + if device_id in self._known_devices: + continue + added_devices.append(device) + self._known_devices[device_id] = device + if added_devices: + _LOGGER.debug("Adding new devices: %s", added_devices) + for listener in self._device_listeners: + listener(added_devices) + + # Remove any device entries that are no longer present + device_registry = dr.async_get(self._hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, self._config_entry.entry_id + ) + for device_entry in device_entries: + device_id = next(iter(device_entry.identifiers))[1] + if device_id in devices: + continue + _LOGGER.info("Removing stale device entry '%s'", device_id) + device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=self._config_entry.entry_id, + ) + async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" @@ -225,10 +276,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool subscriber.cache_policy.store = await async_get_media_event_store(hass, subscriber) subscriber.cache_policy.transcoder = await async_get_transcoder(hass) - async def async_config_reload() -> None: - await hass.config_entries.async_reload(entry.entry_id) - - update_callback = SignalUpdateCallback(hass, async_config_reload, entry) + # The device manager has a single change callback. When the change + # callback is invoked, we update the DeviceListener with the current + # set of devices which will notify any registered listeners with the + # changes. + update_callback = SignalUpdateCallback(hass, entry) subscriber.set_update_callback(update_callback.async_handle_event) try: unsub = await subscriber.start_async() @@ -270,10 +322,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) + update_callback.set_device_manager(device_manager) + entry.async_on_unload(unsub) entry.runtime_data = NestData( subscriber=subscriber, device_manager=device_manager, + register_devices_listener=update_callback.register_devices_listener, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index f5985da9ff8..4b5bee127d0 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -57,16 +57,19 @@ async def async_setup_entry( ) -> None: """Set up the cameras.""" - entities: list[NestCameraBaseEntity] = [] - for device in entry.runtime_data.device_manager.devices.values(): - if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: - continue - if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: - entities.append(NestWebRTCEntity(device)) - elif StreamingProtocol.RTSP in live_stream.supported_protocols: - entities.append(NestRTSPEntity(device)) + def devices_added(devices: list[Device]) -> None: + entities: list[NestCameraBaseEntity] = [] + for device in devices: + if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: + continue + if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: + entities.append(NestWebRTCEntity(device)) + elif StreamingProtocol.RTSP in live_stream.supported_protocols: + entities.append(NestRTSPEntity(device)) - async_add_entities(entities) + async_add_entities(entities) + + entry.runtime_data.register_devices_listener(devices_added) class StreamRefresh: diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 25f39704393..cf1e67ad887 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -82,11 +82,14 @@ async def async_setup_entry( ) -> None: """Set up the client entities.""" - async_add_entities( - ThermostatEntity(device) - for device in entry.runtime_data.device_manager.devices.values() - if ThermostatHvacTrait.NAME in device.traits - ) + def devices_added(devices: list[Device]) -> None: + async_add_entities( + ThermostatEntity(device) + for device in devices + if ThermostatHvacTrait.NAME in device.traits + ) + + entry.runtime_data.register_devices_listener(devices_added) class ThermostatEntity(ClimateEntity): diff --git a/homeassistant/components/nest/quality_scale.yaml b/homeassistant/components/nest/quality_scale.yaml index a91b957e2f2..83282067d37 100644 --- a/homeassistant/components/nest/quality_scale.yaml +++ b/homeassistant/components/nest/quality_scale.yaml @@ -53,16 +53,16 @@ rules: entity-disabled-by-default: todo discovery: todo exception-translations: todo - devices: todo + devices: done docs-supported-devices: todo icon-translations: todo docs-known-limitations: todo - stale-devices: todo + stale-devices: done docs-supported-functions: todo repair-issues: todo reconfiguration-flow: todo entity-category: todo - dynamic-devices: todo + dynamic-devices: done docs-troubleshooting: todo diagnostics: todo docs-use-cases: todo diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index a6fda48fe87..553068bb8b2 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -37,13 +37,16 @@ async def async_setup_entry( ) -> None: """Set up the sensors.""" - entities: list[SensorEntity] = [] - for device in entry.runtime_data.device_manager.devices.values(): - if TemperatureTrait.NAME in device.traits: - entities.append(TemperatureSensor(device)) - if HumidityTrait.NAME in device.traits: - entities.append(HumiditySensor(device)) - async_add_entities(entities) + def devices_added(devices: list[Device]) -> None: + entities: list[SensorEntity] = [] + for device in devices: + if TemperatureTrait.NAME in device.traits: + entities.append(TemperatureSensor(device)) + if HumidityTrait.NAME in device.traits: + entities.append(HumiditySensor(device)) + async_add_entities(entities) + + entry.runtime_data.register_devices_listener(devices_added) class SensorBase(SensorEntity): diff --git a/homeassistant/components/nest/types.py b/homeassistant/components/nest/types.py index bd6cd5cd887..e682a1f10db 100644 --- a/homeassistant/components/nest/types.py +++ b/homeassistant/components/nest/types.py @@ -1,12 +1,16 @@ """Type definitions for Nest.""" +from collections.abc import Callable from dataclasses import dataclass +from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.config_entries import ConfigEntry +type DevicesAddedListener = Callable[[list[Device]], None] + @dataclass class NestData: @@ -14,6 +18,7 @@ class NestData: subscriber: GoogleNestSubscriber device_manager: DeviceManager + register_devices_listener: Callable[[DevicesAddedListener], None] type NestConfigEntry = ConfigEntry[NestData] diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index f21998bbac8..c8eab26d992 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -27,6 +27,8 @@ from .const import ( DATA_CAMERAS, DATA_EVENTS, DOMAIN, + EVENT_TYPE_CONNECTION, + EVENT_TYPE_DISCONNECTION, EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_OFF, EVENT_TYPE_ON, @@ -123,7 +125,13 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): """Entity created.""" await super().async_added_to_hass() - for event_type in (EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_OFF, EVENT_TYPE_ON): + for event_type in ( + EVENT_TYPE_LIGHT_MODE, + EVENT_TYPE_OFF, + EVENT_TYPE_ON, + EVENT_TYPE_CONNECTION, + EVENT_TYPE_DISCONNECTION, + ): self.async_on_remove( async_dispatcher_connect( self.hass, @@ -146,12 +154,19 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): data["home_id"] == self.home.entity_id and data["camera_id"] == self.device.entity_id ): - if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"): + if data[WEBHOOK_PUSH_TYPE] in ( + "NACamera-off", + "NOCamera-off", + "NACamera-disconnection", + "NOCamera-disconnection", + ): self._attr_is_streaming = False self._monitoring = False elif data[WEBHOOK_PUSH_TYPE] in ( "NACamera-on", + "NOCamera-on", WEBHOOK_NACAMERA_CONNECTION, + "NOCamera-connection", ): self._attr_is_streaming = True self._monitoring = True diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index d8ecc72ada7..bdd7fb99e7f 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -127,6 +127,9 @@ EVENT_TYPE_ALARM_STARTED = "alarm_started" EVENT_TYPE_DOOR_TAG_BIG_MOVE = "tag_big_move" EVENT_TYPE_DOOR_TAG_OPEN = "tag_open" EVENT_TYPE_DOOR_TAG_SMALL_MOVE = "tag_small_move" +# Generic events +EVENT_TYPE_CONNECTION = "connection" +EVENT_TYPE_DISCONNECTION = "disconnection" EVENT_TYPE_OFF = "off" EVENT_TYPE_ON = "on" diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 05bb0b28943..8aada0815dc 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.19.0"] + "requirements": ["nibe==2.20.0"] } diff --git a/homeassistant/components/nintendo_parental_controls/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py index 7b3649aaa74..c1aa2458931 100644 --- a/homeassistant/components/nintendo_parental_controls/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -24,6 +24,7 @@ _PLATFORMS: list[Platform] = [ Platform.TIME, Platform.SWITCH, Platform.NUMBER, + Platform.SELECT, ] PLATFORM_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/nintendo_parental_controls/coordinator.py b/homeassistant/components/nintendo_parental_controls/coordinator.py index 0b55db7ea03..abc8f0fdf4e 100644 --- a/homeassistant/components/nintendo_parental_controls/coordinator.py +++ b/homeassistant/components/nintendo_parental_controls/coordinator.py @@ -5,14 +5,18 @@ from __future__ import annotations from datetime import timedelta import logging -from pynintendoauth.exceptions import InvalidOAuthConfigurationException +from pynintendoauth.exceptions import ( + HttpException, + InvalidOAuthConfigurationException, + InvalidSessionTokenException, +) from pynintendoparental import Authenticator, NintendoParental from pynintendoparental.exceptions import NoDevicesFoundException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -58,3 +62,13 @@ class NintendoUpdateCoordinator(DataUpdateCoordinator[None]): translation_domain=DOMAIN, translation_key="no_devices_found", ) from err + except InvalidSessionTokenException as err: + _LOGGER.debug("Session token invalid, will renew on next update") + raise UpdateFailed from err + except HttpException as err: + if err.error_code == "update_required": + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="update_required", + ) from err + raise UpdateFailed(retry_after=900) from err diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index ecb87ef41f9..53c16fa9361 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nintendo_parental_controls", "iot_class": "cloud_polling", - "loggers": ["pynintendoparental"], + "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoparental==2.0.0"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.1.3"] } diff --git a/homeassistant/components/nintendo_parental_controls/select.py b/homeassistant/components/nintendo_parental_controls/select.py new file mode 100644 index 00000000000..bd4a80ae3c1 --- /dev/null +++ b/homeassistant/components/nintendo_parental_controls/select.py @@ -0,0 +1,95 @@ +"""Nintendo Switch Parental Controls select entity definitions.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pynintendoparental.enum import DeviceTimerMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator +from .entity import Device, NintendoDevice + +PARALLEL_UPDATES = 1 + + +class NintendoParentalSelect(StrEnum): + """Store keys for Nintendo Parental Controls select entities.""" + + TIMER_MODE = "timer_mode" + + +@dataclass(kw_only=True, frozen=True) +class NintendoParentalControlsSelectEntityDescription(SelectEntityDescription): + """Description for Nintendo Parental Controls select entities.""" + + get_option: Callable[[Device], DeviceTimerMode | None] + set_option_fn: Callable[[Device, DeviceTimerMode], Coroutine[Any, Any, None]] + options_enum: type[DeviceTimerMode] + + +SELECT_DESCRIPTIONS: tuple[NintendoParentalControlsSelectEntityDescription, ...] = ( + NintendoParentalControlsSelectEntityDescription( + key=NintendoParentalSelect.TIMER_MODE, + translation_key=NintendoParentalSelect.TIMER_MODE, + get_option=lambda device: device.timer_mode, + set_option_fn=lambda device, option: device.set_timer_mode(option), + options_enum=DeviceTimerMode, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NintendoParentalControlsConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform.""" + async_add_devices( + NintendoParentalSelectEntity( + coordinator=entry.runtime_data, + device=device, + description=description, + ) + for device in entry.runtime_data.api.devices.values() + for description in SELECT_DESCRIPTIONS + ) + + +class NintendoParentalSelectEntity(NintendoDevice, SelectEntity): + """Nintendo Parental Controls select entity.""" + + entity_description: NintendoParentalControlsSelectEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + description: NintendoParentalControlsSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + option = self.entity_description.get_option(self._device) + return option.name.lower() if option else None + + @property + def options(self) -> list[str]: + """Return a list of available options.""" + return [option.name.lower() for option in self.entity_description.options_enum] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + enum_option = self.entity_description.options_enum[option.upper()] + await self.entity_description.set_option_fn(self._device, enum_option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/nintendo_parental_controls/services.py b/homeassistant/components/nintendo_parental_controls/services.py index fb23ff14e5a..b50ac07b0f8 100644 --- a/homeassistant/components/nintendo_parental_controls/services.py +++ b/homeassistant/components/nintendo_parental_controls/services.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ATTR_BONUS_TIME, DOMAIN @@ -56,7 +56,7 @@ async def async_add_bonus_time(call: ServiceCall) -> None: bonus_time: int = data[ATTR_BONUS_TIME] device = dr.async_get(call.hass).async_get(device_id) if device is None: - raise HomeAssistantError( + raise ServiceValidationError( translation_domain=DOMAIN, translation_key="device_not_found", ) @@ -66,6 +66,10 @@ async def async_add_bonus_time(call: ServiceCall) -> None: break nintendo_device_id = _get_nintendo_device_id(device) if config_entry and nintendo_device_id: - await config_entry.runtime_data.api.devices[nintendo_device_id].add_extra_time( - bonus_time - ) + return await config_entry.runtime_data.api.devices[ + nintendo_device_id + ].add_extra_time(bonus_time) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + ) diff --git a/homeassistant/components/nintendo_parental_controls/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json index 6c503254424..dfd2fd94dfa 100644 --- a/homeassistant/components/nintendo_parental_controls/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -37,6 +37,15 @@ "name": "Max screentime today" } }, + "select": { + "timer_mode": { + "name": "Restriction mode", + "state": { + "daily": "Same for all days", + "each_day_of_the_week": "Different for each day" + } + } + }, "sensor": { "playing_time": { "name": "Used screen time" @@ -69,8 +78,14 @@ "device_not_found": { "message": "Device not found." }, + "invalid_device": { + "message": "The specified device is not a Nintendo device." + }, "no_devices_found": { "message": "No Nintendo devices found for this account." + }, + "update_required": { + "message": "The Nintendo Switch parental controls integration requires an update due to changes in Nintendo's API." } }, "services": { diff --git a/homeassistant/components/octoprint/icons.json b/homeassistant/components/octoprint/icons.json index 720718fcede..ec41550dfe7 100644 --- a/homeassistant/components/octoprint/icons.json +++ b/homeassistant/components/octoprint/icons.json @@ -1,4 +1,39 @@ { + "entity": { + "sensor": { + "file_name": { + "default": "mdi:printer-3d-nozzle", + "state": { + "unavailable": "mdi:printer-3d-nozzle-off" + } + }, + "status": { + "default": "mdi:printer-3d", + "state": { + "cancelling": "mdi:file-cancel", + "connecting": "mdi:lan-connect", + "detect_serial": "mdi:lan-connect", + "error": "mdi:printer-3d-nozzle-alert", + "finishing": "mdi:printer-3d-nozzle", + "offline": "mdi:printer-3d-off", + "offline_after_error": "mdi:printer-3d-off", + "open_serial": "mdi:lan-connect", + "operational": "mdi:printer-3d", + "paused": "mdi:printer-3d-nozzle-off", + "pausing": "mdi:printer-3d-nozzle", + "printing": "mdi:printer-3d-nozzle", + "printing_sd": "mdi:printer-3d-nozzle", + "printing_streaming": "mdi:file-upload", + "resuming": "mdi:printer-3d-nozzle", + "starting": "mdi:printer-3d-nozzle", + "starting_sd": "mdi:printer-3d-nozzle", + "starting_streaming": "mdi:file-upload", + "transferring_file": "mdi:file-upload", + "unavailable": "mdi:printer-3d-off" + } + } + } + }, "services": { "printer_connect": { "service": "mdi:lan-connect" diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 51dc2b01b66..26ef8721d51 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -23,8 +23,6 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -JOB_PRINTING_STATES = ["Printing from SD", "Printing"] - def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool: return ( @@ -110,10 +108,38 @@ class OctoPrintSensorBase( self._attr_device_info = coordinator.device_info +# Map the strings returned by the OctoPrint API back into values based on the underlying OctoPrint constants. +# See octoprint.util.comm.MahcineCom.getStateString(): +# https://github.com/OctoPrint/OctoPrint/blob/7e7d418dac467e308b24c669a03e8b4256f04b45/src/octoprint/util/comm.py#L965 +_API_STATE_VALUE = { + "Opening serial connection": "open_serial", + "Detecting serial connection": "detect_serial", + "Connecting": "connecting", + "Operational": "operational", + "Starting print from SD": "starting_sd", + "Starting to send file to SD": "starting_streaming", + "Starting": "starting", + "Printing from SD": "printing_sd", + "Sending file to SD": "printing_streaming", + "Printing": "printing", + "Cancelling": "cancelling", + "Pausing": "pausing", + "Paused": "paused", + "Resuming": "resuming", + "Finishing": "finishing", + "Offline": "offline", + "Error": "error", + "Offline after error": "offline_after_error", + "Transferring file to SD": "transferring_file", +} + + class OctoPrintStatusSensor(OctoPrintSensorBase): """Representation of an OctoPrint status sensor.""" - _attr_icon = "mdi:printer-3d" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = list(_API_STATE_VALUE.values()) + _attr_translation_key = "status" def __init__( self, coordinator: OctoprintDataUpdateCoordinator, device_id: str @@ -124,11 +150,14 @@ class OctoPrintStatusSensor(OctoPrintSensorBase): @property def native_value(self): """Return sensor state.""" + + # Get printer data from the coordinator printer: OctoprintPrinterInfo = self.coordinator.data["printer"] if not printer: return None - return printer.state.text + # Translate the string from the API into an internal state value, or return None (Unknown) if no match + return _API_STATE_VALUE.get(printer.state.text) @property def available(self) -> bool: @@ -272,7 +301,7 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase): class OctoPrintFileNameSensor(OctoPrintSensorBase): """Representation of an OctoPrint file name sensor.""" - _attr_icon = "mdi:printer-3d-nozzle" + _attr_translation_key = "file_name" def __init__( self, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 89df5290ce7..66cfe06148d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -47,6 +47,35 @@ "extruder_temperature": { "name": "Extruder temperature" } + }, + "sensor": { + "file_name": { + "name": "Current file" + }, + "status": { + "name": "Current state", + "state": { + "cancelling": "Cancelling", + "connecting": "Connecting", + "detect_serial": "Detecting serial connection", + "error": "[%key:common::state::error%]", + "finishing": "Finishing", + "offline": "Offline", + "offline_after_error": "Offline after error", + "open_serial": "Opening serial connection", + "operational": "Operational", + "paused": "[%key:common::state::paused%]", + "pausing": "Pausing", + "printing": "Printing", + "printing_sd": "Printing from SD", + "printing_streaming": "Sending file to SD", + "resuming": "Resuming", + "starting": "Starting", + "starting_sd": "Starting print from SD", + "starting_streaming": "Starting to send file to SD", + "transferring_file": "Transferring file to SD" + } + } } }, "exceptions": { diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 93063966050..82c74761416 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -49,6 +49,7 @@ SENSORS = [ key="current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda client: client.power.amps, ), OhmeSensorDescription( @@ -57,6 +58,7 @@ SENSORS = [ native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda client: client.power.watts, ), OhmeSensorDescription( @@ -81,6 +83,7 @@ SENSORS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda client: client.battery, ), OhmeSensorDescription( diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 7cbaffe4d63..8dcc167c37e 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.8.0", "python-open-router==0.3.3"] + "requirements": ["openai==2.11.0", "python-open-router==0.3.3"] } diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index d868c88679c..cdfd3b72cfc 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -76,6 +76,7 @@ from .const import ( RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS, RECOMMENDED_WEB_SEARCH_USER_LOCATION, + UNSUPPORTED_CODE_INTERPRETER_MODELS, UNSUPPORTED_IMAGE_MODELS, UNSUPPORTED_MODELS, UNSUPPORTED_WEB_SEARCH_MODELS, @@ -325,7 +326,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): model = options[CONF_CHAT_MODEL] - if not model.startswith(("gpt-5-pro", "gpt-5-codex")): + if not model.startswith(tuple(UNSUPPORTED_CODE_INTERPRETER_MODELS)): step_schema.update( { vol.Optional( @@ -337,14 +338,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): elif CONF_CODE_INTERPRETER in options: options.pop(CONF_CODE_INTERPRETER) - if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"): - if model.startswith("gpt-5.1"): - reasoning_options = ["none", "low", "medium", "high"] - elif model.startswith("gpt-5"): - reasoning_options = ["minimal", "low", "medium", "high"] - else: - reasoning_options = ["low", "medium", "high"] - + if reasoning_options := self._get_reasoning_options(model): step_schema.update( { vol.Optional( @@ -471,6 +465,24 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): errors=errors, ) + def _get_reasoning_options(self, model: str) -> list[str]: + """Get reasoning effort options based on model.""" + if not model.startswith(("o", "gpt-5")) or model.startswith("gpt-5-pro"): + return [] + + MODELS_REASONING_MAP = { + "gpt-5.2-pro": ["medium", "high", "xhigh"], + "gpt-5.2": ["none", "low", "medium", "high", "xhigh"], + "gpt-5.1": ["none", "low", "medium", "high"], + "gpt-5": ["minimal", "low", "medium", "high"], + "": ["low", "medium", "high"], # The default case + } + + for prefix, options in MODELS_REASONING_MAP.items(): + if model.startswith(prefix): + return options + return [] # pragma: no cover + async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" location_data: dict[str, str] = {} diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 0c282688a58..3ba488d87db 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -74,6 +74,25 @@ UNSUPPORTED_IMAGE_MODELS: list[str] = [ "gpt-4-turbo", ] +UNSUPPORTED_CODE_INTERPRETER_MODELS: list[str] = [ + "gpt-5-pro", + "gpt-5.2-pro", + "gpt-5-codex", + "gpt-5.1-codex", + "gpt-5.2-codex", +] + +UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS: list[str] = [ + "o1", + "o3", + "o4", + "gpt-3.5", + "gpt-4-turbo", + "gpt-4o", + "gpt-5-mini", + "gpt-5-nano", +] + RECOMMENDED_CONVERSATION_OPTIONS = { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index a3910e86d8b..c1113a4339e 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -94,6 +94,7 @@ from .const import ( RECOMMENDED_VERBOSITY, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS, + UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS, ) if TYPE_CHECKING: @@ -487,8 +488,6 @@ class OpenAIBaseLLMEntity(Entity): model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), input=messages, max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), user=chat_log.conversation_id, store=False, stream=True, @@ -505,12 +504,23 @@ class OpenAIBaseLLMEntity(Entity): } model_args["include"] = ["reasoning.encrypted_content"] + if ( + not model_args["model"].startswith("gpt-5") + or model_args["reasoning"]["effort"] == "none" # type: ignore[index] + ): + model_args["top_p"] = options.get(CONF_TOP_P, RECOMMENDED_TOP_P) + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + if model_args["model"].startswith("gpt-5"): model_args["text"] = { "verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY) } - if model_args["model"].startswith("gpt-5.1"): + if not model_args["model"].startswith( + tuple(UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS) + ): model_args["prompt_cache_retention"] = "24h" tools: list[ToolParam] = [] diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 8f129578a36..feaf57c928d 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==2.8.0"] + "requirements": ["openai==2.11.0"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index f107b4d5405..4b870d23c30 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -147,7 +147,8 @@ "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]", "minimal": "Minimal", - "none": "None" + "none": "None", + "xhigh": "X-High" } }, "search_context_size": { diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 2960cefe10c..af7bc1cd1cc 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -56,4 +56,5 @@ WIDGET_TO_WATER_HEATER_ENTITY = { CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, + "io:AtlanticDomesticHotWaterProductionV2_CETHI_V4_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, } diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 3e7b5f32272..66cec82fb61 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -159,7 +159,8 @@ class OverseerrWebhookManager: """Handle webhook.""" data = await request.json() LOGGER.debug("Received webhook payload: %s", data) - if data["notification_type"].startswith("MEDIA"): + notification_type = data["notification_type"] + if notification_type.startswith(("REQUEST_", "ISSUE_", "MEDIA_")): await self.entry.runtime_data.async_refresh() async_dispatcher_send(hass, EVENT_KEY, data) return HomeAssistantView.json({"message": "ok"}) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index da1fc051608..b955d2a50a4 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -22,6 +22,10 @@ REGISTERED_NOTIFICATIONS = ( | NotificationType.REQUEST_AVAILABLE | NotificationType.REQUEST_PROCESSING_FAILED | NotificationType.REQUEST_AUTOMATICALLY_APPROVED + | NotificationType.ISSUE_REPORTED + | NotificationType.ISSUE_COMMENTED + | NotificationType.ISSUE_RESOLVED + | NotificationType.ISSUE_REOPENED ) JSON_PAYLOAD = ( '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py index 2149dcbec7c..af6c3c30945 100644 --- a/homeassistant/components/overseerr/coordinator.py +++ b/homeassistant/components/overseerr/coordinator.py @@ -6,7 +6,6 @@ from python_overseerr import ( OverseerrAuthenticationError, OverseerrClient, OverseerrConnectionError, - RequestCount, ) from yarl import URL @@ -18,11 +17,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER +from .models import OverseerrData type OverseerrConfigEntry = ConfigEntry[OverseerrCoordinator] -class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): +class OverseerrCoordinator(DataUpdateCoordinator[OverseerrData]): """Class to manage fetching Overseerr data.""" config_entry: OverseerrConfigEntry @@ -49,10 +49,12 @@ class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): self.url = URL.build(host=host, port=port, scheme="https" if ssl else "http") self.push = False - async def _async_update_data(self) -> RequestCount: + async def _async_update_data(self) -> OverseerrData: """Fetch data from API endpoint.""" try: - return await self.client.get_request_count() + requests = await self.client.get_request_count() + issues = await self.client.get_issue_count() + return OverseerrData(requests=requests, issues=issues) except OverseerrAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 3c4321ebb37..031c13122c9 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -2,12 +2,12 @@ "domain": "overseerr", "name": "Overseerr", "after_dependencies": ["cloud"], - "codeowners": ["@joostlek"], + "codeowners": ["@joostlek", "@AmGarera"], "config_flow": true, "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/overseerr", "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["python-overseerr==0.7.1"] + "requirements": ["python-overseerr==0.8.0"] } diff --git a/homeassistant/components/overseerr/models.py b/homeassistant/components/overseerr/models.py new file mode 100644 index 00000000000..8e040a06fb8 --- /dev/null +++ b/homeassistant/components/overseerr/models.py @@ -0,0 +1,13 @@ +"""Data models for Overseerr integration.""" + +from dataclasses import dataclass + +from python_overseerr import IssueCount, RequestCount + + +@dataclass +class OverseerrData: + """Data model for Overseerr coordinator.""" + + requests: RequestCount + issues: IssueCount diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py index 8f0cf93b7ce..dbac8d94914 100644 --- a/homeassistant/components/overseerr/sensor.py +++ b/homeassistant/components/overseerr/sensor.py @@ -3,8 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass -from python_overseerr import RequestCount - from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -16,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import REQUESTS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .entity import OverseerrEntity +from .models import OverseerrData PARALLEL_UPDATES = 0 @@ -24,7 +23,7 @@ PARALLEL_UPDATES = 0 class OverseerrSensorEntityDescription(SensorEntityDescription): """Describes Overseerr config sensor entity.""" - value_fn: Callable[[RequestCount], int] + value_fn: Callable[[OverseerrData], int] SENSORS: tuple[OverseerrSensorEntityDescription, ...] = ( @@ -32,43 +31,73 @@ SENSORS: tuple[OverseerrSensorEntityDescription, ...] = ( key="total_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.total, + value_fn=lambda data: data.requests.total, ), OverseerrSensorEntityDescription( key="movie_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.movie, + value_fn=lambda data: data.requests.movie, ), OverseerrSensorEntityDescription( key="tv_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.tv, + value_fn=lambda data: data.requests.tv, ), OverseerrSensorEntityDescription( key="pending_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.pending, + value_fn=lambda data: data.requests.pending, ), OverseerrSensorEntityDescription( key="declined_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.declined, + value_fn=lambda data: data.requests.declined, ), OverseerrSensorEntityDescription( key="processing_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.processing, + value_fn=lambda data: data.requests.processing, ), OverseerrSensorEntityDescription( key="available_requests", native_unit_of_measurement=REQUESTS, state_class=SensorStateClass.TOTAL, - value_fn=lambda count: count.available, + value_fn=lambda data: data.requests.available, + ), + OverseerrSensorEntityDescription( + key="total_issues", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.issues.total, + ), + OverseerrSensorEntityDescription( + key="open_issues", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.issues.open, + ), + OverseerrSensorEntityDescription( + key="closed_issues", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.issues.closed, + ), + OverseerrSensorEntityDescription( + key="video_issues", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.issues.video, + ), + OverseerrSensorEntityDescription( + key="audio_issues", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.issues.audio, + ), + OverseerrSensorEntityDescription( + key="subtitle_issues", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.issues.subtitles, ), ) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index b9a706d2539..4e8829f269f 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -50,26 +50,62 @@ } }, "sensor": { + "audio_issues": { + "name": "Audio issues", + "state": { + "measurement": "issues" + } + }, "available_requests": { "name": "Available requests" }, + "closed_issues": { + "name": "Closed issues", + "state": { + "measurement": "issues" + } + }, "declined_requests": { "name": "Declined requests" }, "movie_requests": { "name": "Movie requests" }, + "open_issues": { + "name": "Open issues", + "state": { + "measurement": "issues" + } + }, "pending_requests": { "name": "Pending requests" }, "processing_requests": { "name": "Processing requests" }, + "subtitle_issues": { + "name": "Subtitle issues", + "state": { + "measurement": "issues" + } + }, + "total_issues": { + "name": "Total issues", + "state": { + "measurement": "issues" + } + }, "total_requests": { "name": "Total requests" }, "tv_requests": { "name": "TV requests" + }, + "video_issues": { + "name": "Video issues", + "state": { + "measurement": "issues" + } } } }, diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index a433a63f31f..dda1f3cca05 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -250,6 +250,10 @@ class PhilipsTVMediaPlayer(PhilipsJsEntity, MediaPlayerEntity): media_content_type=MediaType.CHANNEL, can_play=True, can_expand=False, + thumbnail=self.get_browse_image_url( + MediaType.CHANNEL, + f"{self._tv.channel_list_id}/{channel['ccid']}", + ), ) for channel in self._tv.channels_current ] @@ -289,6 +293,10 @@ class PhilipsTVMediaPlayer(PhilipsJsEntity, MediaPlayerEntity): media_content_type=MediaType.CHANNEL, can_play=True, can_expand=False, + thumbnail=self.get_browse_image_url( + MediaType.CHANNEL, + f"{list_id}/{channel['ccid']}", + ), ) for channel in favorites.get("channels", []) ] @@ -412,7 +420,11 @@ class PhilipsTVMediaPlayer(PhilipsJsEntity, MediaPlayerEntity): if media_content_type == MediaType.APP and media_content_id: return await self._tv.getApplicationIcon(media_content_id) if media_content_type == MediaType.CHANNEL and media_content_id: - return await self._tv.getChannelLogo(media_content_id) + list_id, _, channel_id = media_content_id.partition("/") + if not channel_id: + channel_id = list_id + list_id = "all" + return await self._tv.getChannelLogo(channel_id, list_id) except ConnectionFailure: _LOGGER.warning("Failed to fetch image") return None, None diff --git a/homeassistant/components/plex/models.py b/homeassistant/components/plex/models.py index f2fa3f60d24..3558666eadb 100644 --- a/homeassistant/components/plex/models.py +++ b/homeassistant/components/plex/models.py @@ -2,6 +2,8 @@ import logging +import plexapi.playqueue + from homeassistant.components.media_player import MediaType from homeassistant.helpers.template import result_as_boolean from homeassistant.util import dt as dt_util @@ -167,7 +169,10 @@ class PlexMediaSearchResult: if isinstance(resume, str): resume = result_as_boolean(resume) if resume: - return self.media.viewOffset + media = self.media + if isinstance(media, plexapi.playqueue.PlayQueue) and len(media.items) > 0: + media = media.items[0] + return media.viewOffset return 0 @property @@ -177,3 +182,11 @@ class PlexMediaSearchResult: if isinstance(shuffle, str): shuffle = result_as_boolean(shuffle) return shuffle + + @property + def continuous(self) -> bool: + """Return value of continuous parameter.""" + continuous = self._params.get("continuous", False) + if isinstance(continuous, str): + continuous = result_as_boolean(continuous) + return continuous diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 1ff7820a2c0..9843c9244f3 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -174,6 +174,7 @@ def process_plex_payload( search_query = content.copy() shuffle = search_query.pop("shuffle", 0) + continuous = search_query.pop("continuous", 0) # Remove internal kwargs before passing copy to plexapi for internal_key in ("resume", "offset"): @@ -181,9 +182,12 @@ def process_plex_payload( media = plex_server.lookup_media(content_type, **search_query) - if supports_playqueues and (isinstance(media, list) or shuffle): + if supports_playqueues and (isinstance(media, list) or shuffle or continuous): playqueue = plex_server.create_playqueue( - media, includeRelated=0, shuffle=shuffle + media, + includeRelated=0, + shuffle=1 if shuffle else 0, + continuous=1 if continuous else 0, ) return PlexMediaSearchResult(playqueue, content) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 4ed100b538d..7cbf5a22a4f 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -7,6 +7,7 @@ from plugwise import GwEntityData, Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, + InvalidSetupError, InvalidXMLError, PlugwiseError, ResponseError, @@ -31,6 +32,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData """Class to manage fetching Plugwise data from single endpoint.""" _connected: bool = False + _current_devices: set[str] + _stored_devices: set[str] + new_devices: set[str] config_entry: PlugwiseConfigEntry @@ -59,14 +63,31 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT), websession=async_get_clientsession(hass, verify_ssl=False), ) - self._current_devices: set[str] = set() - self.new_devices: set[str] = set() + self._current_devices = set() + self._stored_devices = set() + self.new_devices = set() async def _connect(self) -> None: - """Connect to the Plugwise Smile.""" + """Connect to the Plugwise Smile. + + A Version object is received when the connection succeeds. + """ version = await self.api.connect() self._connected = isinstance(version, Version) + async def _async_setup(self) -> None: + """Initialize the update_data process.""" + device_reg = dr.async_get(self.hass) + device_entries = dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ) + self._stored_devices = { + identifier[1] + for device_entry in device_entries + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + async def _async_update_data(self) -> dict[str, GwEntityData]: """Fetch data from Plugwise.""" try: @@ -83,10 +104,15 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData translation_domain=DOMAIN, translation_key="authentication_failed", ) from err + except InvalidSetupError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_setup", + ) from err except (InvalidXMLError, ResponseError) as err: raise UpdateFailed( translation_domain=DOMAIN, - translation_key="invalid_xml_data", + translation_key="response_error", ) from err except PlugwiseError as err: raise UpdateFailed( @@ -104,12 +130,16 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None: """Add new Plugwise devices, remove non-existing devices.""" - # Check for new or removed devices - self.new_devices = set(data) - self._current_devices - removed_devices = self._current_devices - set(data) - self._current_devices = set(data) - - if removed_devices: + set_of_data = set(data) + # Check for new or removed devices, + # 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty) + # this is required for the proper initialization of all the present platform entities. + self.new_devices = set_of_data - self._current_devices + current_devices = ( + self._stored_devices if not self._current_devices else self._current_devices + ) + self._current_devices = set_of_data + if current_devices - set_of_data: # device(s) to remove self._async_remove_devices(data) def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None: @@ -118,26 +148,26 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData device_list = dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ) + # First find the Plugwise via_device gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)}) assert gateway_device is not None via_device_id = gateway_device.id - # Then remove the connected orphaned device(s) for device_entry in device_list: for identifier in device_entry.identifiers: - if identifier[0] == DOMAIN: - if ( - device_entry.via_device_id == via_device_id - and identifier[1] not in data - ): - device_reg.async_update_device( - device_entry.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - LOGGER.debug( - "Removed %s device %s %s from device_registry", - DOMAIN, - device_entry.model, - identifier[1], - ) + if ( + identifier[0] == DOMAIN + and device_entry.via_device_id == via_device_id + and identifier[1] not in data + ): + device_reg.async_update_device( + device_entry.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + LOGGER.debug( + "Removed %s device/zone %s %s from device_registry", + DOMAIN, + device_entry.model, + identifier[1], + ) diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 2202f8c3ed9..afecac11ec4 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from plugwise.constants import GwEntityData +from plugwise import GwEntityData from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST from homeassistant.helpers.device_registry import ( @@ -30,37 +30,43 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): super().__init__(coordinator) self._dev_id = device_id - configuration_url: str | None = None - if entry := self.coordinator.config_entry: - configuration_url = f"http://{entry.data[CONF_HOST]}" + api = coordinator.api + gateway_id = api.gateway_id + entry = coordinator.config_entry - data = coordinator.data[device_id] + # Link configuration-URL for the gateway device + configuration_url = ( + f"http://{entry.data[CONF_HOST]}" + if device_id == gateway_id and entry + else None + ) + + # Build connections set connections = set() - if mac := data.get("mac_address"): + if mac := self.device.get("mac_address"): connections.add((CONNECTION_NETWORK_MAC, mac)) - if mac := data.get("zigbee_mac_address"): - connections.add((CONNECTION_ZIGBEE, mac)) + if zigbee_mac := self.device.get("zigbee_mac_address"): + connections.add((CONNECTION_ZIGBEE, zigbee_mac)) + # Set base device info self._attr_device_info = DeviceInfo( configuration_url=configuration_url, identifiers={(DOMAIN, device_id)}, connections=connections, - manufacturer=data.get("vendor"), - model=data.get("model"), - model_id=data.get("model_id"), - name=coordinator.api.smile.name, - sw_version=data.get("firmware"), - hw_version=data.get("hardware"), + manufacturer=self.device.get("vendor"), + model=self.device.get("model"), + model_id=self.device.get("model_id"), + name=api.smile.name, + sw_version=self.device.get("firmware"), + hw_version=self.device.get("hardware"), ) - if device_id != coordinator.api.gateway_id: + # Add extra info if not the gateway device + if device_id != gateway_id: self._attr_device_info.update( { - ATTR_NAME: data.get(ATTR_NAME), - ATTR_VIA_DEVICE: ( - DOMAIN, - str(self.coordinator.api.gateway_id), - ), + ATTR_NAME: self.device.get(ATTR_NAME), + ATTR_VIA_DEVICE: (DOMAIN, gateway_id), } ) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index b7a28b4563c..383a5232c58 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.10.0"], + "requirements": ["plugwise==1.11.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index d539243d287..c83c71ee9bc 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -37,7 +37,6 @@ SELECT_TYPES = ( PlugwiseSelectEntityDescription( key=SELECT_SCHEDULE, translation_key=SELECT_SCHEDULE, - entity_category=EntityCategory.CONFIG, options_key="available_schedules", ), PlugwiseSelectEntityDescription( diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 69074cfc67b..69e67b1d5a6 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -319,7 +319,10 @@ "failed_to_connect": { "message": "[%key:common::config_flow::error::cannot_connect%]" }, - "invalid_xml_data": { + "invalid_setup": { + "message": "Add your Adam instead of your Anna, see the documentation" + }, + "response_error": { "message": "[%key:component::plugwise::config::error::response_error%]" }, "set_schedule_first": { diff --git a/homeassistant/components/pooldose/__init__.py b/homeassistant/components/pooldose/__init__.py index adc65094a9a..12d55ed544f 100644 --- a/homeassistant/components/pooldose/__init__.py +++ b/homeassistant/components/pooldose/__init__.py @@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/pooldose/const.py b/homeassistant/components/pooldose/const.py index 7e205d418ea..c0a7949d71b 100644 --- a/homeassistant/components/pooldose/const.py +++ b/homeassistant/components/pooldose/const.py @@ -7,15 +7,16 @@ from homeassistant.const import UnitOfTemperature, UnitOfVolume, UnitOfVolumeFlo DOMAIN = "pooldose" MANUFACTURER = "SEKO" -# Mapping of device units (upper case) to Home Assistant units +# Unit mappings for select entities (water meter and flow rate) +# Keys match API values exactly: lowercase for m3/m3/h, uppercase L for L/L/s UNIT_MAPPING: dict[str, str] = { # Temperature units "°C": UnitOfTemperature.CELSIUS, "°F": UnitOfTemperature.FAHRENHEIT, # Volume flow rate units - "M3/H": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - "L/S": UnitOfVolumeFlowRate.LITERS_PER_SECOND, + "m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND, # Volume units "L": UnitOfVolume.LITERS, - "M3": UnitOfVolume.CUBIC_METERS, + "m3": UnitOfVolume.CUBIC_METERS, } diff --git a/homeassistant/components/pooldose/icons.json b/homeassistant/components/pooldose/icons.json index 5d52b03e7db..bd56bc4c283 100644 --- a/homeassistant/components/pooldose/icons.json +++ b/homeassistant/components/pooldose/icons.json @@ -97,6 +97,32 @@ "default": "mdi:ph" } }, + "select": { + "cl_type_dosing_method": { + "default": "mdi:beaker" + }, + "cl_type_dosing_set": { + "default": "mdi:pool" + }, + "flow_rate_unit": { + "default": "mdi:pipe-valve" + }, + "orp_type_dosing_method": { + "default": "mdi:beaker" + }, + "orp_type_dosing_set": { + "default": "mdi:water-check" + }, + "ph_type_dosing_method": { + "default": "mdi:beaker" + }, + "ph_type_dosing_set": { + "default": "mdi:ph" + }, + "water_meter_unit": { + "default": "mdi:water" + } + }, "sensor": { "cl": { "default": "mdi:pool" diff --git a/homeassistant/components/pooldose/select.py b/homeassistant/components/pooldose/select.py new file mode 100644 index 00000000000..4d0732e7d2b --- /dev/null +++ b/homeassistant/components/pooldose/select.py @@ -0,0 +1,160 @@ +"""Select entities for the Seko PoolDose integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING, Any, cast + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory, UnitOfVolume, UnitOfVolumeFlowRate +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PooldoseConfigEntry +from .const import UNIT_MAPPING +from .entity import PooldoseEntity + +if TYPE_CHECKING: + from .coordinator import PooldoseCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class PooldoseSelectEntityDescription(SelectEntityDescription): + """Describes PoolDose select entity.""" + + use_unit_conversion: bool = False + + +SELECT_DESCRIPTIONS: tuple[PooldoseSelectEntityDescription, ...] = ( + PooldoseSelectEntityDescription( + key="water_meter_unit", + translation_key="water_meter_unit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[UnitOfVolume.LITERS, UnitOfVolume.CUBIC_METERS], + use_unit_conversion=True, + ), + PooldoseSelectEntityDescription( + key="flow_rate_unit", + translation_key="flow_rate_unit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[ + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.LITERS_PER_SECOND, + ], + use_unit_conversion=True, + ), + PooldoseSelectEntityDescription( + key="ph_type_dosing_set", + translation_key="ph_type_dosing_set", + entity_category=EntityCategory.CONFIG, + options=["alcalyne", "acid"], + ), + PooldoseSelectEntityDescription( + key="ph_type_dosing_method", + translation_key="ph_type_dosing_method", + entity_category=EntityCategory.CONFIG, + options=["off", "proportional", "on_off", "timed"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="orp_type_dosing_set", + translation_key="orp_type_dosing_set", + entity_category=EntityCategory.CONFIG, + options=["low", "high"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="orp_type_dosing_method", + translation_key="orp_type_dosing_method", + entity_category=EntityCategory.CONFIG, + options=["off", "proportional", "on_off", "timed"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="cl_type_dosing_set", + translation_key="cl_type_dosing_set", + entity_category=EntityCategory.CONFIG, + options=["low", "high"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="cl_type_dosing_method", + translation_key="cl_type_dosing_method", + entity_category=EntityCategory.CONFIG, + options=["off", "proportional", "on_off", "timed"], + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PooldoseConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PoolDose select entities from a config entry.""" + if TYPE_CHECKING: + assert config_entry.unique_id is not None + + coordinator = config_entry.runtime_data + select_data = coordinator.data["select"] + serial_number = config_entry.unique_id + + async_add_entities( + PooldoseSelect(coordinator, serial_number, coordinator.device_info, description) + for description in SELECT_DESCRIPTIONS + if description.key in select_data + ) + + +class PooldoseSelect(PooldoseEntity, SelectEntity): + """Select entity for the Seko PoolDose Python API.""" + + entity_description: PooldoseSelectEntityDescription + + def __init__( + self, + coordinator: PooldoseCoordinator, + serial_number: str, + device_info: Any, + description: PooldoseSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(coordinator, serial_number, device_info, description, "select") + self._async_update_attrs() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + def _async_update_attrs(self) -> None: + """Update select attributes.""" + data = cast(dict, self.get_data()) + api_value = cast(str, data["value"]) + + # Convert API value to Home Assistant unit if unit conversion is enabled + if self.entity_description.use_unit_conversion: + # Map API value (e.g., "m3") to HA unit (e.g., "m³") + self._attr_current_option = UNIT_MAPPING.get(api_value, api_value) + else: + self._attr_current_option = api_value + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + # Convert Home Assistant unit to API value if unit conversion is enabled + if self.entity_description.use_unit_conversion: + # Invert UNIT_MAPPING to get API value from HA unit + reverse_map = {v: k for k, v in UNIT_MAPPING.items()} + api_value = reverse_map.get(option, option) + else: + api_value = option + + await self.coordinator.client.set_select(self.entity_description.key, api_value) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/pooldose/sensor.py b/homeassistant/components/pooldose/sensor.py index a18e200cd5a..6441581daa2 100644 --- a/homeassistant/components/pooldose/sensor.py +++ b/homeassistant/components/pooldose/sensor.py @@ -32,14 +32,14 @@ _LOGGER = logging.getLogger(__name__) class PooldoseSensorEntityDescription(SensorEntityDescription): """Describes PoolDose sensor entity.""" - use_dynamic_unit: bool = False + use_unit_conversion: bool = False SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = ( PooldoseSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - use_dynamic_unit=True, + use_unit_conversion=True, ), PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH), PooldoseSensorEntityDescription( @@ -57,14 +57,14 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = ( key="flow_rate", translation_key="flow_rate", device_class=SensorDeviceClass.VOLUME_FLOW_RATE, - use_dynamic_unit=True, + use_unit_conversion=True, ), PooldoseSensorEntityDescription( key="water_meter_total_permanent", translation_key="water_meter_total_permanent", device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, - use_dynamic_unit=True, + use_unit_conversion=True, ), PooldoseSensorEntityDescription( key="ph_type_dosing", @@ -227,12 +227,12 @@ class PooldoseSensor(PooldoseEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if ( - self.entity_description.use_dynamic_unit + self.entity_description.use_unit_conversion and (data := self.get_data()) is not None and (device_unit := data.get("unit")) ): - # Map device unit (upper case) to Home Assistant unit, return None if unknown - return UNIT_MAPPING.get(device_unit.upper()) + # Map device unit to Home Assistant unit, return None if unknown + return UNIT_MAPPING.get(device_unit) # Fall back to static unit from entity description return super().native_unit_of_measurement diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index de646f2f404..5b02c7495fe 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -97,6 +97,62 @@ "name": "pH target" } }, + "select": { + "cl_type_dosing_method": { + "name": "Chlorine dosing method", + "state": { + "off": "[%key:common::state::off%]", + "on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]", + "proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]", + "timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]" + } + }, + "cl_type_dosing_set": { + "name": "Chlorine dosing set", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]" + } + }, + "flow_rate_unit": { + "name": "Flow rate unit" + }, + "orp_type_dosing_method": { + "name": "ORP dosing method", + "state": { + "off": "[%key:common::state::off%]", + "on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]", + "proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]", + "timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]" + } + }, + "orp_type_dosing_set": { + "name": "ORP dosing set", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]" + } + }, + "ph_type_dosing_method": { + "name": "pH dosing method", + "state": { + "off": "[%key:common::state::off%]", + "on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]", + "proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]", + "timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]" + } + }, + "ph_type_dosing_set": { + "name": "pH dosing set", + "state": { + "acid": "Acid (pH-)", + "alcalyne": "Alkaline (pH+)" + } + }, + "water_meter_unit": { + "name": "Water meter unit" + } + }, "sensor": { "cl": { "name": "Chlorine" diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 09ac5b42b60..8ede90f2718 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -32,9 +32,17 @@ PRESET_HOLIDAY = "holiday" PRESET_ALTERNATE = "alternate" +PRESET_DEFAULT = "default" + STATE_CIRCULATE = "circulate" -PRESET_MODES = [PRESET_HOME, PRESET_ALTERNATE, PRESET_AWAY, PRESET_HOLIDAY] +PRESET_MODES = [ + PRESET_DEFAULT, + PRESET_HOME, + PRESET_ALTERNATE, + PRESET_AWAY, + PRESET_HOLIDAY, +] OPERATION_LIST = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] CT30_FAN_OPERATION_LIST = [FAN_ON, FAN_AUTO] @@ -67,6 +75,7 @@ CODE_TO_TEMP_STATE = {0: HVACAction.IDLE, 1: HVACAction.HEATING, 2: HVACAction.C CODE_TO_FAN_STATE = {0: FAN_OFF, 1: FAN_ON} PRESET_MODE_TO_CODE = { + PRESET_DEFAULT: -1, PRESET_HOME: 0, PRESET_ALTERNATE: 1, PRESET_AWAY: 2, diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 7009a8af360..86a49e6b0c6 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -4,7 +4,6 @@ from datetime import datetime import logging from ical.event import Event -from ical.timeline import Timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -49,18 +48,12 @@ class RemoteCalendarEntity( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._timeline: Timeline | None = None + self._event: CalendarEvent | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - if self._timeline is None: - return None - now = dt_util.now() - events = self._timeline.active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return self._event async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -86,12 +79,14 @@ class RemoteCalendarEntity( """ await super().async_update() - def _get_timeline() -> Timeline | None: - """Return the next active event.""" + def next_event() -> CalendarEvent | None: now = dt_util.now() - return self.coordinator.data.timeline_tz(now.tzinfo) + events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None - self._timeline = await self.hass.async_add_executor_job(_get_timeline) + self._event = await self.hass.async_add_executor_job(next_event) def _get_calendar_event(event: Event) -> CalendarEvent: diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 392d435d857..807033c6d35 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==11.1.0"] + "requirements": ["ical==12.1.2"] } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 0a4c88124e8..5fbe1ba3951 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -4,8 +4,9 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging +from random import uniform from time import time from typing import Any @@ -34,6 +35,7 @@ from .const import ( BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, CONF_BC_ONLY, CONF_BC_PORT, + CONF_FIRMWARE_CHECK_TIME, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -212,15 +214,41 @@ async def async_setup_entry( config_entry=config_entry, name=f"reolink.{host.api.nvr_name}.firmware", update_method=async_check_firmware_update, - update_interval=FIRMWARE_UPDATE_INTERVAL, + update_interval=None, # Do not fetch data automatically, resume 24h schedule ) + async def first_firmware_check(*args: Any) -> None: + """Start first firmware check delayed to continue 24h schedule.""" + firmware_coordinator.update_interval = FIRMWARE_UPDATE_INTERVAL + await firmware_coordinator.async_refresh() + host.cancel_first_firmware_check = None + + # get update time from config entry + check_time_sec = config_entry.data.get(CONF_FIRMWARE_CHECK_TIME) + if check_time_sec is None: + check_time_sec = uniform(0, 86400) + data = { + **config_entry.data, + CONF_FIRMWARE_CHECK_TIME: check_time_sec, + } + hass.config_entries.async_update_entry(config_entry, data=data) + # If camera WAN blocked, firmware check fails and takes long, do not prevent setup - config_entry.async_create_background_task( - hass, - firmware_coordinator.async_refresh(), - f"Reolink firmware check {config_entry.entry_id}", + now = datetime.now(UTC) + check_time = timedelta(seconds=check_time_sec) + delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0) + firmware_check_delay = check_time - delta_midnight + if firmware_check_delay < timedelta(0): + firmware_check_delay += timedelta(days=1) + _LOGGER.debug( + "Scheduling first Reolink %s firmware check in %s", + host.api.nvr_name, + firmware_check_delay, ) + host.cancel_first_firmware_check = async_call_later( + hass, firmware_check_delay, first_firmware_check + ) + # Fetch initial data so we have data when entities subscribe try: await device_coordinator.async_config_entry_first_refresh() @@ -312,6 +340,8 @@ async def async_unload_entry( host.api.baichuan.unregister_callback(f"camera_{channel}_wake") if host.cancel_refresh_privacy_mode is not None: host.cancel_refresh_privacy_mode() + if host.cancel_first_firmware_check is not None: + host.cancel_first_firmware_check() return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index db2d105984b..59d594a5406 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -6,6 +6,7 @@ CONF_USE_HTTPS = "use_https" CONF_BC_PORT = "baichuan_port" CONF_BC_ONLY = "baichuan_only" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" +CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" # Conserve battery by not waking the battery cameras each minute during normal update # Most props are cached in the Home Hub and updated, but some are skipped diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 57af2404321..7b7cc48c1dd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -130,6 +130,7 @@ class ReolinkHost: self._lost_subscription_start: bool = False self._lost_subscription: bool = False self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None + self.cancel_first_firmware_check: CALLBACK_TYPE | None = None @callback def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 56c7a509cca..d65bd5d5abf 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -2,10 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass, field import logging -from typing import Any from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError from pyrisco.common import Partition, System, Zone @@ -22,8 +19,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CONCURRENCY, @@ -35,6 +34,10 @@ from .const import ( TYPE_LOCAL, ) from .coordinator import RiscoDataUpdateCoordinator, RiscoEventsDataUpdateCoordinator +from .models import LocalData +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -45,14 +48,6 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -@dataclass -class LocalData: - """A data class for local data passed to the platforms.""" - - system: RiscoLocal - partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict) - - def is_local(entry: ConfigEntry) -> bool: """Return whether the entry represents an instance with local communication.""" return entry.data.get(CONF_TYPE) == TYPE_LOCAL @@ -176,3 +171,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Risco integration services.""" + + await async_setup_services(hass) + + return True diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index ef3280fe232..88fae4de7c2 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -55,3 +55,5 @@ DEFAULT_ADVANCED_OPTIONS = { CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, CONF_CONCURRENCY: DEFAULT_CONCURRENCY, } + +SERVICE_SET_TIME = "set_time" diff --git a/homeassistant/components/risco/icons.json b/homeassistant/components/risco/icons.json new file mode 100644 index 00000000000..97abbcca6f7 --- /dev/null +++ b/homeassistant/components/risco/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "set_time": { + "service": "mdi:clock-edit" + } + } +} diff --git a/homeassistant/components/risco/models.py b/homeassistant/components/risco/models.py new file mode 100644 index 00000000000..07777839e88 --- /dev/null +++ b/homeassistant/components/risco/models.py @@ -0,0 +1,15 @@ +"""Models for Risco integration.""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from pyrisco import RiscoLocal + + +@dataclass +class LocalData: + """A data class for local data passed to the platforms.""" + + system: RiscoLocal + partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict) diff --git a/homeassistant/components/risco/services.py b/homeassistant/components/risco/services.py new file mode 100644 index 00000000000..4c2e632b2ec --- /dev/null +++ b/homeassistant/components/risco/services.py @@ -0,0 +1,63 @@ +"""Services for Risco integration.""" + +from datetime import datetime + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME, CONF_TYPE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, SERVICE_SET_TIME, TYPE_LOCAL +from .models import LocalData + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Create the Risco Services/Actions.""" + + async def _set_time(service_call: ServiceCall) -> None: + config_entry_id = service_call.data[ATTR_CONFIG_ENTRY_ID] + time = service_call.data.get(ATTR_TIME) + + # Validate config entry exists + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + ) + + # Validate config entry is loaded + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + # Validate config entry is local (not cloud) + if entry.data.get(CONF_TYPE) != TYPE_LOCAL: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_local_entry", + ) + + time_to_send = time + if time is None: + time_to_send = datetime.now() + + local_data: LocalData = hass.data[DOMAIN][config_entry_id] + + await local_data.system.set_time(time_to_send) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_TIME, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TIME): cv.datetime, + } + ), + service_func=_set_time, + ) diff --git a/homeassistant/components/risco/services.yaml b/homeassistant/components/risco/services.yaml new file mode 100644 index 00000000000..88a7b4da27a --- /dev/null +++ b/homeassistant/components/risco/services.yaml @@ -0,0 +1,11 @@ +set_time: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: risco + time: + required: false + selector: + datetime: diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 60367b9d0e6..79c1e7b7b4b 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -71,6 +71,17 @@ } } }, + "exceptions": { + "config_entry_not_found": { + "message": "Config entry not found. Please check that the config entry ID is correct." + }, + "config_entry_not_loaded": { + "message": "Config entry is not loaded. Please ensure the Risco integration is set up correctly." + }, + "not_local_entry": { + "message": "This service only works with local Risco connections." + } + }, "options": { "step": { "ha_to_risco": { @@ -105,5 +116,21 @@ "title": "Map Risco states to Home Assistant states" } } + }, + "services": { + "set_time": { + "description": "Sets the time of an alarm panel.", + "fields": { + "config_entry_id": { + "description": "The Risco alarm panel to set the time for.", + "name": "Config entry" + }, + "time": { + "description": "The time to send to the alarm panel. Leave it empty to use the Home Assistant system time.", + "name": "Time" + } + }, + "name": "Set the alarm panel time" + } } } diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index a2b390f4d2e..309d87bd299 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 5d67233f7bc..3c419873b66 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -18,6 +18,7 @@ from roborock.data import UserData from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager from roborock.map.map_parser import MapParserConfig +from roborock.mqtt.session import MqttSessionUnauthorized from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant @@ -36,10 +37,12 @@ from .const import ( PLATFORMS, ) from .coordinator import ( + RoborockB01Q7UpdateCoordinator, RoborockConfigEntry, RoborockCoordinators, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, + RoborockDataUpdateCoordinatorB01, RoborockWashingMachineUpdateCoordinator, RoborockWetDryVacUpdateCoordinator, ) @@ -92,6 +95,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="no_user_agreement", ) from err + except MqttSessionUnauthorized as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="mqtt_unauthorized", + ) from err except RoborockException as err: _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( @@ -125,13 +133,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> for coord in coordinators if isinstance(coord, RoborockDataUpdateCoordinatorA01) ] - if len(v1_coords) + len(a01_coords) == 0: + b01_coords = [ + coord + for coord in coordinators + if isinstance(coord, RoborockDataUpdateCoordinatorB01) + ] + if len(v1_coords) + len(a01_coords) + len(b01_coords) == 0: raise ConfigEntryNotReady( "No devices were able to successfully setup", translation_domain=DOMAIN, translation_key="no_coordinators", ) - entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords) + entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords, b01_coords) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -202,12 +215,17 @@ def build_setup_functions( Coroutine[ Any, Any, - RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None, + RoborockDataUpdateCoordinator + | RoborockDataUpdateCoordinatorA01 + | RoborockDataUpdateCoordinatorB01 + | None, ] ]: """Create a list of setup functions that can later be called asynchronously.""" coordinators: list[ - RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 + RoborockDataUpdateCoordinator + | RoborockDataUpdateCoordinatorA01 + | RoborockDataUpdateCoordinatorB01 ] = [] for device in devices: _LOGGER.debug("Creating device %s: %s", device.name, device) @@ -223,6 +241,12 @@ def build_setup_functions( coordinators.append( RoborockWashingMachineUpdateCoordinator(hass, entry, device, device.zeo) ) + elif device.b01_q7_properties is not None: + coordinators.append( + RoborockB01Q7UpdateCoordinator( + hass, entry, device, device.b01_q7_properties + ) + ) else: _LOGGER.warning( "Not adding device %s because its protocol version %s or category %s is not supported", @@ -235,8 +259,15 @@ def build_setup_functions( async def setup_coordinator( - coordinator: RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01, -) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: + coordinator: RoborockDataUpdateCoordinator + | RoborockDataUpdateCoordinatorA01 + | RoborockDataUpdateCoordinatorB01, +) -> ( + RoborockDataUpdateCoordinator + | RoborockDataUpdateCoordinatorA01 + | RoborockDataUpdateCoordinatorB01 + | None +): """Set up a single coordinator.""" try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 54911795cd8..fe070e10321 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -8,12 +8,18 @@ import logging from typing import Any, TypeVar from propcache.api import cached_property +from roborock import B01Props from roborock.data import HomeDataScene from roborock.devices.device import RoborockDevice from roborock.devices.traits.a01 import DyadApi, ZeoApi +from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.exceptions import RoborockDeviceBusy, RoborockException -from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol +from roborock.roborock_message import ( + RoborockB01Props, + RoborockDyadDataProtocol, + RoborockZeoProtocol, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS @@ -43,6 +49,12 @@ from .models import DeviceState SCAN_INTERVAL = timedelta(seconds=30) +# Roborock devices have a known issue where they go offline for a short period +# around 3AM local time for ~1 minute and reset both the local connection +# and MQTT connection. To avoid log spam, we will avoid reporting failures refreshing +# data until this duration has passed. +MIN_UNAVAILABLE_DURATION = timedelta(minutes=2) + _LOGGER = logging.getLogger(__name__) @@ -52,12 +64,17 @@ class RoborockCoordinators: v1: list[RoborockDataUpdateCoordinator] a01: list[RoborockDataUpdateCoordinatorA01] + b01: list[RoborockDataUpdateCoordinatorB01] def values( self, - ) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]: + ) -> list[ + RoborockDataUpdateCoordinator + | RoborockDataUpdateCoordinatorA01 + | RoborockDataUpdateCoordinatorB01 + ]: """Return all coordinators.""" - return self.v1 + self.a01 + return self.v1 + self.a01 + self.b01 type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] @@ -102,6 +119,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]): # Keep track of last attempt to refresh maps/rooms to know when to try again. self._last_home_update_attempt: datetime self.last_home_update: datetime | None = None + # Tracks the last successful update to control when we report failure + # to the base class. This is reset on successful data update. + self._last_update_success_time: datetime | None = None @cached_property def dock_device_info(self) -> DeviceInfo: @@ -169,7 +189,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]): self.last_home_update = dt_util.utcnow() async def _verify_api(self) -> None: - """Verify that the api is reachable. If it is not, switch clients.""" + """Verify that the api is reachable.""" if self._device.is_connected: if self._device.is_local_connected: async_delete_issue( @@ -217,26 +237,27 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]): try: # Update device props and standard api information await self._update_device_prop() + except UpdateFailed: + if self._should_suppress_update_failure(): + _LOGGER.debug( + "Suppressing update failure until unavailable duration passed" + ) + return self.data + raise - # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL - # since the last map update, you can update the map. - new_status = self.properties_api.status - if ( - new_status.in_cleaning - and (dt_util.utcnow() - self._last_home_update_attempt) - > IMAGE_CACHE_INTERVAL - ) or self.last_update_state != new_status.state_name: - self._last_home_update_attempt = dt_util.utcnow() - try: - await self.update_map() - except HomeAssistantError as err: - _LOGGER.debug("Failed to update map: %s", err) - except RoborockException as ex: - _LOGGER.debug("Failed to update data: %s", ex) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_data_fail", - ) from ex + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + new_status = self.properties_api.status + if ( + new_status.in_cleaning + and (dt_util.utcnow() - self._last_home_update_attempt) + > IMAGE_CACHE_INTERVAL + ) or self.last_update_state != new_status.state_name: + self._last_home_update_attempt = dt_util.utcnow() + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) if self.properties_api.status.in_cleaning: if self._device.is_local_connected: @@ -248,6 +269,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]): else: self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL self.last_update_state = self.properties_api.status.state_name + self._last_update_success_time = dt_util.utcnow() + _LOGGER.debug("Data update successful %s", self._last_update_success_time) return DeviceState( status=self.properties_api.status, dnd_timer=self.properties_api.dnd, @@ -255,6 +278,23 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]): clean_summary=self.properties_api.clean_summary, ) + def _should_suppress_update_failure(self) -> bool: + """Determine if we should suppress update failure reporting. + + We suppress reporting update failures until a minimum duration has + passed since the last successful update. This is used to avoid reporting + the device as unavailable for short periods, a known issue. + + The intent is to apply to routine background state refreshes and not + other failures such as the first update or map updates. + """ + if self._last_update_success_time is None: + # Never had a successful update, do not suppress + return False + failure_duration = dt_util.utcnow() - self._last_update_success_time + _LOGGER.debug("Update failure duration: %s", failure_duration) + return failure_duration < MIN_UNAVAILABLE_DURATION + async def get_routines(self) -> list[HomeDataScene]: """Get routines.""" try: @@ -394,7 +434,14 @@ class RoborockWashingMachineUpdateCoordinator( async def _async_update_data( self, ) -> dict[RoborockZeoProtocol, StateType]: - return await self.api.query_values(self.request_protocols) + try: + return await self.api.query_values(self.request_protocols) + except RoborockException as ex: + _LOGGER.debug("Failed to update washing machine data: %s", ex) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex class RoborockWetDryVacUpdateCoordinator( @@ -425,4 +472,99 @@ class RoborockWetDryVacUpdateCoordinator( async def _async_update_data( self, ) -> dict[RoborockDyadDataProtocol, StateType]: - return await self.api.query_values(self.request_protocols) + try: + return await self.api.query_values(self.request_protocols) + except RoborockException as ex: + _LOGGER.debug("Failed to update wet dry vac data: %s", ex) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex + + +class RoborockDataUpdateCoordinatorB01(DataUpdateCoordinator[B01Props]): + """Class to manage fetching data from the API for B01 devices.""" + + config_entry: RoborockConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + device: RoborockDevice, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=A01_UPDATE_INTERVAL, + ) + self._device = device + self.device_info = DeviceInfo( + name=device.name, + identifiers={(DOMAIN, device.duid)}, + manufacturer="Roborock", + model=device.product.model, + sw_version=device.device_info.fv, + ) + + @cached_property + def duid(self) -> str: + """Get the unique id of the device as specified by Roborock.""" + return self._device.duid + + @cached_property + def duid_slug(self) -> str: + """Get the slug of the duid.""" + return slugify(self.duid) + + @property + def device(self) -> RoborockDevice: + """Get the RoborockDevice.""" + return self._device + + +class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01): + """Coordinator for B01 Q7 devices.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + device: RoborockDevice, + api: Q7PropertiesApi, + ) -> None: + """Initialize.""" + super().__init__(hass, config_entry, device) + self.api = api + self.request_protocols: list[RoborockB01Props] = [ + RoborockB01Props.STATUS, + RoborockB01Props.MAIN_BRUSH, + RoborockB01Props.SIDE_BRUSH, + RoborockB01Props.DUST_BAG_USED, + RoborockB01Props.MOP_LIFE, + RoborockB01Props.MAIN_SENSOR, + RoborockB01Props.CLEANING_TIME, + RoborockB01Props.REAL_CLEAN_TIME, + RoborockB01Props.HYPA, + ] + + async def _async_update_data( + self, + ) -> B01Props: + try: + data = await self.api.query_values(self.request_protocols) + except RoborockException as ex: + _LOGGER.debug("Failed to update Q7 data: %s", ex) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex + if data is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) + return data diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index 07b4d7ae91e..2dea15e1e96 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -13,7 +13,11 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .coordinator import ( + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, + RoborockDataUpdateCoordinatorB01, +) class RoborockEntity(Entity): @@ -124,3 +128,23 @@ class RoborockCoordinatedEntityA01( ) CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id + + +class RoborockCoordinatedEntityB01( + RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinatorB01] +): + """Representation of coordinated Roborock Entity.""" + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinatorB01, + ) -> None: + """Initialize the coordinated Roborock Device.""" + RoborockEntity.__init__( + self, + unique_id=unique_id, + device_info=coordinator.device_info, + ) + CoordinatorEntity.__init__(self, coordinator=coordinator) + self._attr_unique_id = unique_id diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 090a498b751..993081f8049 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==3.10.10", + "python-roborock==3.19.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2dba430fde9..bd376c03255 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -73,7 +73,9 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ key="dust_collection_mode", translation_key="dust_collection_mode", api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, - value_fn=lambda api: api.dust_collection_mode.mode.name, # type: ignore[union-attr] + value_fn=lambda api: ( + mode.name if (mode := api.dust_collection_mode.mode) is not None else None # type: ignore[union-attr] + ), entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( RoborockDockDustCollectionModeCode.keys() diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 6eb633ca939..24f2d340e38 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -8,12 +8,14 @@ import datetime import logging from roborock.data import ( + B01Props, DyadError, RoborockDockErrorCode, RoborockDockTypeCode, RoborockDyadStateCode, RoborockErrorCode, RoborockStateCode, + WorkStatusMapping, ZeoError, ZeoState, ) @@ -34,9 +36,11 @@ from .coordinator import ( RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, + RoborockDataUpdateCoordinatorB01, ) from .entity import ( RoborockCoordinatedEntityA01, + RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1, RoborockEntity, ) @@ -64,6 +68,13 @@ class RoborockSensorDescriptionA01(SensorEntityDescription): data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol +@dataclass(frozen=True, kw_only=True) +class RoborockSensorDescriptionB01(SensorEntityDescription): + """A class that describes Roborock B01 sensors.""" + + value_fn: Callable[[B01Props], StateType] + + def _dock_error_value_fn(state: DeviceState) -> str | None: if ( status := state.status.dock_error_status @@ -326,6 +337,71 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ ), ] +Q7_B01_SENSOR_DESCRIPTIONS = [ + RoborockSensorDescriptionB01( + key="q7_status", + value_fn=lambda data: data.status_name, + translation_key="q7_status", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=WorkStatusMapping.keys(), + ), + RoborockSensorDescriptionB01( + key="main_brush_time_left", + value_fn=lambda data: data.main_brush_time_left, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + translation_key="main_brush_time_left", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionB01( + key="side_brush_time_left", + value_fn=lambda data: data.side_brush_time_left, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + translation_key="side_brush_time_left", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionB01( + key="filter_time_left", + value_fn=lambda data: data.filter_time_left, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + translation_key="filter_time_left", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionB01( + key="sensor_time_left", + value_fn=lambda data: data.sensor_dirty_time_left, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + translation_key="sensor_time_left", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionB01( + key="mop_life_time_left", + value_fn=lambda data: data.mop_life_time_left, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + translation_key="mop_life_time_left", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionB01( + key="total_cleaning_time", + value_fn=lambda data: data.real_clean_time, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + translation_key="total_cleaning_time", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -354,6 +430,12 @@ async def async_setup_entry( for description in A01_SENSOR_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) + entities.extend( + RoborockSensorEntityB01(coordinator, description) + for coordinator in coordinators.b01 + for description in Q7_B01_SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.data) is not None + ) async_add_entities(entities) @@ -440,3 +522,23 @@ class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.coordinator.data[self.entity_description.data_protocol] + + +class RoborockSensorEntityB01(RoborockCoordinatedEntityB01, SensorEntity): + """Representation of a B01 Roborock sensor.""" + + entity_description: RoborockSensorDescriptionB01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorB01, + description: RoborockSensorDescriptionB01, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index ae0f939eba0..14013131f27 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -213,6 +213,25 @@ "mop_drying_remaining_time": { "name": "Mop drying remaining time" }, + "mop_life_time_left": { + "name": "Mop life time left" + }, + "q7_status": { + "name": "Status", + "state": { + "charging": "[%key:common::state::charging%]", + "docking": "[%key:component::roborock::entity::sensor::status::state::docking%]", + "mop_airdrying": "Mop air drying", + "mop_cleaning": "Mop cleaning", + "moping": "Mopping", + "paused": "[%key:common::state::paused%]", + "sleeping": "Sleeping", + "sweep_moping": "Sweep mopping", + "sweep_moping_2": "Sweep mopping", + "updating": "[%key:component::roborock::entity::sensor::status::state::updating%]", + "waiting_for_orders": "Waiting for orders" + } + }, "sensor_time_left": { "name": "Sensor time left" }, @@ -426,6 +445,9 @@ "map_failure": { "message": "Something went wrong creating the map" }, + "mqtt_unauthorized": { + "message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration." + }, "no_coordinators": { "message": "No devices were able to successfully setup" }, diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json index 3051aa5ac63..6364dbb18d4 100644 --- a/homeassistant/components/route_b_smart_meter/manifest.json +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -13,5 +13,5 @@ "momonga.sk_wrapper_logger" ], "quality_scale": "bronze", - "requirements": ["pyserial==3.5", "momonga==0.2.0"] + "requirements": ["pyserial==3.5", "momonga==0.3.0"] } diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index b3cc5fe0263..268bd43b7db 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.46.0" + "async-upnp-client==0.46.1" ], "ssdp": [ { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 44d59cdfea0..4df2a92526f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -416,8 +416,8 @@ def warn_dip( _LOGGER.warning( ( "Entity %s %shas state class total_increasing, but its state is not" - " strictly increasing. Triggered by state %s (%s) with last_updated set" - " to %s. Please %s" + " strictly increasing. Triggered by state %s (previous state: %s) with" + " last_updated set to %s. Please %s" ), entity_id, f"from integration {domain} " if domain else "", diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index d1b3ba34b91..8e4480f92d7 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -63,6 +63,7 @@ from .repairs import ( async_manage_open_wifi_ap_issue, async_manage_outbound_websocket_incorrectly_enabled_issue, ) +from .services import async_setup_services from .utils import ( async_create_issue_unsupported_firmware, async_migrate_rpc_virtual_components_unique_ids, @@ -117,6 +118,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := config.get(DOMAIN)) is not None: hass.data[DOMAIN] = {CONF_COAP_PORT: conf[CONF_COAP_PORT]} + async_setup_services(hass) + return True diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index c7678a4b57a..17e68f03573 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -343,3 +343,6 @@ MODEL_FRANKEVER_IRRIGATION_CONTROLLER = "Irrigation" ROLE_GENERIC = "generic" TRV_CHANNEL = 0 + +ATTR_KEY = "key" +ATTR_VALUE = "value" diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index cabef1d5c0a..f12ddea711b 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -105,5 +105,13 @@ } } } + }, + "services": { + "get_kvs_value": { + "service": "mdi:import" + }, + "set_kvs_value": { + "service": "mdi:export" + } } } diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml index f97faa88c27..d3d6255ba6f 100644 --- a/homeassistant/components/shelly/quality_scale.yaml +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: The integration does not register services. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: The integration does not register services. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -24,9 +20,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: The integration does not register services. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py new file mode 100644 index 00000000000..759b62603e8 --- /dev/null +++ b/homeassistant/components/shelly/services.py @@ -0,0 +1,170 @@ +"""Support for services.""" + +from typing import TYPE_CHECKING, Any, cast + +from aioshelly.const import RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.util.json import JsonValueType + +from .const import ATTR_KEY, ATTR_VALUE, CONF_SLEEP_PERIOD, DOMAIN +from .coordinator import ShellyConfigEntry +from .utils import get_device_entry_gen + +SERVICE_GET_KVS_VALUE = "get_kvs_value" +SERVICE_SET_KVS_VALUE = "set_kvs_value" +SERVICE_GET_KVS_VALUE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_KEY): str, + } +) +SERVICE_SET_KVS_VALUE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(str, int, float, bool, dict, list, None), + } +) + + +@callback +def async_get_config_entry_for_service_call( + call: ServiceCall, +) -> ShellyConfigEntry: + """Get the config entry related to a service call (by device ID).""" + device_registry = dr.async_get(call.hass) + device_id = call.data[ATTR_DEVICE_ID] + + if (device_entry := device_registry.async_get(device_id)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + for entry_id in device_entry.config_entries: + config_entry = call.hass.config_entries.async_get_entry(entry_id) + + if TYPE_CHECKING: + assert config_entry + + if config_entry.domain != DOMAIN: + continue + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"device": config_entry.title}, + ) + if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="kvs_not_supported", + translation_placeholders={"device": config_entry.title}, + ) + if config_entry.data.get(CONF_SLEEP_PERIOD, 0) > 0: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="kvs_not_supported", + translation_placeholders={"device": config_entry.title}, + ) + return config_entry + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"device_id": device_id}, + ) + + +async def _async_execute_action( + call: ServiceCall, method: str, args: tuple +) -> dict[str, Any]: + """Execute action on the device.""" + config_entry = async_get_config_entry_for_service_call(call) + + runtime_data = config_entry.runtime_data + + if not runtime_data.rpc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_initialized", + translation_placeholders={"device": config_entry.title}, + ) + + action_method = getattr(runtime_data.rpc.device, method) + + try: + response = await action_method(*args) + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_error", + translation_placeholders={"device": config_entry.title}, + ) from err + except DeviceConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": config_entry.title}, + ) from err + else: + return cast(dict[str, Any], response) + + +async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: + """Handle the get_kvs_value service call.""" + key = call.data[ATTR_KEY] + + response = await _async_execute_action(call, "kvs_get", (key,)) + + result: dict[str, JsonValueType] = {} + result[ATTR_VALUE] = response[ATTR_VALUE] + + return result + + +async def async_set_kvs_value(call: ServiceCall) -> None: + """Handle the set_kvs_value service call.""" + await _async_execute_action( + call, "kvs_set", (call.data[ATTR_KEY], call.data[ATTR_VALUE]) + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for Shelly integration.""" + for service, method, schema, response in ( + ( + SERVICE_GET_KVS_VALUE, + async_get_kvs_value, + SERVICE_GET_KVS_VALUE_SCHEMA, + SupportsResponse.ONLY, + ), + ( + SERVICE_SET_KVS_VALUE, + async_set_kvs_value, + SERVICE_SET_KVS_VALUE_SCHEMA, + SupportsResponse.NONE, + ), + ): + hass.services.async_register( + DOMAIN, + service, + method, + schema=schema, + supports_response=response, + ) diff --git a/homeassistant/components/shelly/services.yaml b/homeassistant/components/shelly/services.yaml new file mode 100644 index 00000000000..b559e17d9c4 --- /dev/null +++ b/homeassistant/components/shelly/services.yaml @@ -0,0 +1,27 @@ +get_kvs_value: + fields: + device_id: + required: true + selector: + device: + integration: shelly + key: + required: true + selector: + text: + +set_kvs_value: + fields: + device_id: + required: true + selector: + device: + integration: shelly + key: + required: true + selector: + text: + value: + required: true + selector: + object: diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 6be47bb9408..2f81de2e643 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -537,6 +537,12 @@ "voltmeter_value": { "name": "Voltmeter value" }, + "voltmeter_value_with_channel_name": { + "name": "{channel_name} voltmeter value" + }, + "voltmeter_with_channel_name": { + "name": "{channel_name} voltmeter" + }, "water_consumption": { "name": "Water consumption" }, @@ -603,6 +609,9 @@ "auth_error": { "message": "Authentication failed for {device}, please update your credentials" }, + "config_entry_not_found": { + "message": "Config entry for device ID {device_id} not found" + }, "device_communication_action_error": { "message": "Device communication error occurred while calling action for {entity} of {device}" }, @@ -612,12 +621,24 @@ "device_not_found": { "message": "{device} not found while configuring device automation triggers" }, + "device_not_initialized": { + "message": "{device} not initialized" + }, + "entry_not_loaded": { + "message": "Config entry not loaded for {device}" + }, "firmware_unsupported": { "message": "{device} is running an unsupported firmware, please update the firmware" }, + "invalid_device_id": { + "message": "Invalid device ID specified: {device_id}" + }, "invalid_trigger": { "message": "Invalid device automation trigger (type, subtype): {trigger}" }, + "kvs_not_supported": { + "message": "{device} does not support KVS" + }, "ota_update_connection_error": { "message": "Device communication error occurred while triggering OTA update for {device}" }, @@ -627,6 +648,9 @@ "rpc_call_action_error": { "message": "RPC call error occurred while calling action for {entity} of {device}" }, + "rpc_call_error": { + "message": "RPC call error occurred for {device}" + }, "update_error": { "message": "An error occurred while retrieving data from {device}" }, @@ -748,5 +772,39 @@ "manual": "Enter address manually" } } + }, + "services": { + "get_kvs_value": { + "description": "Get a value from the device's Key-Value Storage.", + "fields": { + "device_id": { + "description": "The ID of the Shelly device to get the KVS value from.", + "name": "Device" + }, + "key": { + "description": "The name of the key for which the KVS value will be retrieved.", + "name": "Key" + } + }, + "name": "Get KVS value" + }, + "set_kvs_value": { + "description": "Set a value in the device's Key-Value Storage.", + "fields": { + "device_id": { + "description": "The ID of the Shelly device to set the KVS value.", + "name": "Device" + }, + "key": { + "description": "The name of the key under which the KVS value will be stored.", + "name": "Key" + }, + "value": { + "description": "Value to set.", + "name": "Value" + } + }, + "name": "Set KVS value" + } } } diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 38a80ddd354..4cd02431148 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 +from simplipy.websocket import EVENT_SECRET_ALERT_TRIGGERED, WebsocketEvent from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -103,7 +104,12 @@ class TriggeredBinarySensor(SimpliSafeEntity, BinarySensorEntity): device_class: BinarySensorDeviceClass, ) -> None: """Initialize.""" - super().__init__(simplisafe, system, device=sensor) + super().__init__( + simplisafe, + system, + device=sensor, + additional_websocket_events=[EVENT_SECRET_ALERT_TRIGGERED], + ) self._attr_device_class = device_class self._device: SensorV3 @@ -113,6 +119,18 @@ class TriggeredBinarySensor(SimpliSafeEntity, BinarySensorEntity): """Update the entity with the provided REST API data.""" self._attr_is_on = self._device.triggered + @callback + def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: + """Update the entity when new data comes from the websocket.""" + LOGGER.debug( + "Binary sensor device serial # %s received event %s", + self._device.serial, + event.event_type, + ) + # Secret Alerts can only set a sensor to on + self._attr_is_on = True + self.async_reset_error_count() + class BatteryBinarySensor(SimpliSafeEntity, BinarySensorEntity): """Define a SimpliSafe battery binary sensor entity.""" diff --git a/homeassistant/components/simplisafe/entity.py b/homeassistant/components/simplisafe/entity.py index ff1dd49e9fc..27d7d8f2b4d 100644 --- a/homeassistant/components/simplisafe/entity.py +++ b/homeassistant/components/simplisafe/entity.py @@ -13,6 +13,7 @@ from simplipy.websocket import ( EVENT_LOCK_UNLOCKED, EVENT_POWER_OUTAGE, EVENT_POWER_RESTORED, + EVENT_SECRET_ALERT_TRIGGERED, WebsocketEvent, ) @@ -41,7 +42,11 @@ DEFAULT_CONFIG_URL = "https://webapp.simplisafe.com/new/#/dashboard" DEFAULT_ENTITY_MODEL = "Alarm control panel" DEFAULT_ERROR_THRESHOLD = 2 -WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] +WEBSOCKET_EVENTS_REQUIRING_SERIAL = [ + EVENT_LOCK_LOCKED, + EVENT_LOCK_UNLOCKED, + EVENT_SECRET_ALERT_TRIGGERED, +] class SimpliSafeEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 1b431a43479..2490404e41f 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -31,5 +31,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.5.0"] + "requirements": ["pysmartthings==3.5.1"] } diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index 6ac2f60d7a9..7899d8db351 100644 --- a/homeassistant/components/smartthings/number.py +++ b/homeassistant/components/smartthings/number.py @@ -41,7 +41,7 @@ async def async_setup_entry( ) for device in entry_data.devices.values() for component in device.status - if component in ("cooler", "freezer") + if component in ("cooler", "freezer", "onedoor") and Capability.THERMOSTAT_COOLING_SETPOINT in device.status[component] ) async_add_entities(entities) @@ -176,7 +176,8 @@ class SmartThingsRefrigeratorTemperatureNumberEntity(SmartThingsEntity, NumberEn self._attr_translation_key = { "cooler": "cooler_temperature", "freezer": "freezer_temperature", - }[component] + "onedoor": "target_temperature", + }.get(component) @property def range(self) -> dict[str, int]: diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2a73de39185..209b0da0e00 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -912,7 +912,9 @@ CAPABILITY_TO_SENSORS: dict[ if Capability.CUSTOM_OUTING_MODE in status else None ), - component_fn=lambda component: component in {"freezer", "cooler"}, + component_fn=( + lambda component: component in {"freezer", "cooler", "onedoor"} + ), component_translation_key={ "freezer": "freezer_temperature", "cooler": "cooler_temperature", @@ -1299,7 +1301,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): ) if self.entity_description.component_translation_key and component != MAIN: self._attr_translation_key = ( - self.entity_description.component_translation_key[component] + self.entity_description.component_translation_key.get(component) ) @property diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 4cae24e2e3f..29387d22d64 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -151,6 +151,9 @@ "hood_fan_speed": { "name": "Fan speed" }, + "target_temperature": { + "name": "Target temperature" + }, "washer_rinse_cycles": { "name": "Rinse cycles", "unit_of_measurement": "cycles" diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 81a3f964084..e84b3991073 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.45"] + "requirements": ["python-smarttub==0.0.46"] } diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 1458e018655..31defde5fa5 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.11"], + "requirements": ["pysmlight==0.2.13"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bcc252a7d8d..146a18555e2 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "bronze", "requirements": [ "defusedxml==0.7.1", - "soco==0.30.12", + "soco==0.30.13", "sonos-websocket==0.1.3" ], "ssdp": [ diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index c61f047d3e3..ed2d4add7ba 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -72,6 +72,7 @@ if TYPE_CHECKING: NEVER_TIME = -1200.0 RESUB_COOLDOWN_SECONDS = 10.0 +WAIT_FOR_GROUPS_TIMEOUT = 30.0 EVENT_CHARGING = { "CHARGING": True, "NOT_CHARGING": False, @@ -690,7 +691,8 @@ class SonosSpeaker: async def async_offline(self) -> None: """Handle removal of speaker when unavailable.""" - assert self._subscription_lock is not None + if not self._subscription_lock: + self._subscription_lock = asyncio.Lock() async with self._subscription_lock: await self._async_offline() @@ -1014,11 +1016,21 @@ class SonosSpeaker: speakers: list[SonosSpeaker], ) -> None: """Form a group with other players.""" + # When joining multiple speakers, build the group incrementally and + # wait for the grouping to complete after each join. This avoids race + # conditions in zone topology updates. async with config_entry.runtime_data.topology_condition: - group: list[SonosSpeaker] = await hass.async_add_executor_job( - master.join, speakers - ) - await SonosSpeaker.wait_for_groups(hass, config_entry, [group]) + join_list: list[SonosSpeaker] = [] + for speaker in speakers: + _LOGGER.debug("Join %s to %s", speaker.zone_name, master.zone_name) + join_list.append(speaker) + group: list[SonosSpeaker] = await hass.async_add_executor_job( + master.join, join_list + ) + await SonosSpeaker.wait_for_groups(hass, config_entry, [group]) + _LOGGER.debug( + "Join Complete %s to %s", speaker.zone_name, master.zone_name + ) @soco_error() def unjoin(self) -> None: @@ -1212,7 +1224,7 @@ class SonosSpeaker: return True try: - async with asyncio.timeout(5): + async with asyncio.timeout(WAIT_FOR_GROUPS_TIMEOUT): while not _test_groups(groups): await config_entry.runtime_data.topology_condition.wait() except TimeoutError: diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 31dd5b003b7..c9776b63d9c 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -155,9 +155,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown exception while validating connection") return "unknown" - if "uuid" in status: - await self.async_set_unique_id(status["uuid"]) - self._abort_if_unique_id_configured() + if "uuid" not in status: + _LOGGER.exception("Discovered server did not provide a uuid") + return "missing_uuid" + + await self.async_set_unique_id(status["uuid"]) + self._abort_if_unique_id_configured() return None diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 47147f21f40..a48b0d5855f 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -13,5 +13,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pysqueezebox"], + "quality_scale": "silver", "requirements": ["pysqueezebox==0.13.0"] } diff --git a/homeassistant/components/squeezebox/quality_scale.yaml b/homeassistant/components/squeezebox/quality_scale.yaml new file mode 100644 index 00000000000..c9926a6d1ee --- /dev/null +++ b/homeassistant/components/squeezebox/quality_scale.yaml @@ -0,0 +1,69 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration only has entity_actions, which are setup in the entity async_setup_entry. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: done + comment: Future enhancements, 1) separate manual and discovery flows, 2) allow for discovery of multiple LMS and selection of one. PR 153958 + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration doesn't have an auth flow. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: todo + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: There aren't any entities that should be disabled by default. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 3333c143c81..25a4d51a783 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_uuid": "Your LMS did not provide a unique identifier and is not compatible with this integration. Please check and update your LMS version.", "no_server_found": "Could not automatically discover server.", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6ae7d8275da..61015e95809 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.46.0"] + "requirements": ["async-upnp-client==0.46.1"] } diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 1a4a9a4c6db..9d4dc0764ee 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, cast +from typing import Any, Unpack, cast import voluptuous as vol @@ -13,14 +13,14 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionCheckerType, + ConditionChecker, + ConditionCheckParams, ConditionConfig, condition_trace_set_result, condition_trace_update_result, - trace_condition_function, ) from homeassistant.helpers.sun import get_astral_event_date -from homeassistant.helpers.typing import ConfigType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { @@ -154,17 +154,16 @@ class SunCondition(Condition): assert config.options is not None self._options = config.options - async def async_get_checker(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Wrap action method with sun based condition.""" before = self._options.get("before") after = self._options.get("after") before_offset = self._options.get("before_offset") after_offset = self._options.get("after_offset") - @trace_condition_function - def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + def sun_if(**kwargs: Unpack[ConditionCheckParams]) -> bool: """Validate time based if-condition.""" - return sun(hass, before, after, before_offset, after_offset) + return sun(self._hass, before, after, before_offset, after_offset) return sun_if diff --git a/homeassistant/components/sun/manifest.json b/homeassistant/components/sun/manifest.json index b693509b27a..54e75410f57 100644 --- a/homeassistant/components/sun/manifest.json +++ b/homeassistant/components/sun/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@home-assistant/core"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sun", + "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", "single_config_entry": true diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index e8f8db9a4ae..47d4317ce97 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from PySrDaliGateway import DaliGateway @@ -18,11 +19,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER from .types import DaliCenterConfigEntry, DaliCenterData -_PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE] _LOGGER = logging.getLogger(__name__) @@ -47,7 +49,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - ) from exc try: - devices = await gateway.discover_devices() + devices, scenes = await asyncio.gather( + gateway.discover_devices(), + gateway.discover_scenes(), + ) except DaliGatewayError as exc: raise ConfigEntryNotReady( "Unable to discover devices from the gateway" @@ -58,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, gw_sn)}, identifiers={(DOMAIN, gw_sn)}, manufacturer=MANUFACTURER, name=gateway.name, @@ -68,6 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - entry.runtime_data = DaliCenterData( gateway=gateway, devices=devices, + scenes=scenes, ) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/sunricher_dali/config_flow.py b/homeassistant/components/sunricher_dali/config_flow.py index 27321fa9a1f..ebb329c2013 100644 --- a/homeassistant/components/sunricher_dali/config_flow.py +++ b/homeassistant/components/sunricher_dali/config_flow.py @@ -18,11 +18,13 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_SERIAL_NUMBER, DOMAIN @@ -132,3 +134,15 @@ class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery to update existing entries.""" + mac_address = format_mac(discovery_info.macaddress) + serial_number = mac_address.replace(":", "").upper() + + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + return self.async_abort(reason="no_dhcp_flow") diff --git a/homeassistant/components/sunricher_dali/entity.py b/homeassistant/components/sunricher_dali/entity.py new file mode 100644 index 00000000000..7cc0da20ca8 --- /dev/null +++ b/homeassistant/components/sunricher_dali/entity.py @@ -0,0 +1,57 @@ +"""Base entity for Sunricher DALI integration.""" + +from __future__ import annotations + +import logging + +from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +class DaliCenterEntity(Entity): + """Base entity for DALI Center objects (devices, scenes, etc.).""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, dali_object: DaliObjectBase) -> None: + """Initialize base entity.""" + self._dali_object = dali_object + self._attr_unique_id = dali_object.unique_id + self._unavailable_logged = False + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Register availability listener.""" + self.async_on_remove( + self._dali_object.register_listener( + CallbackEventType.ONLINE_STATUS, + self._handle_availability, + ) + ) + + @callback + def _handle_availability(self, available: bool) -> None: + """Handle availability changes.""" + if not available and not self._unavailable_logged: + _LOGGER.info("Entity %s became unavailable", self.entity_id) + self._unavailable_logged = True + elif available and self._unavailable_logged: + _LOGGER.info("Entity %s is back online", self.entity_id) + self._unavailable_logged = False + + self._attr_available = available + self.schedule_update_ha_state() + + +class DaliDeviceEntity(DaliCenterEntity): + """Base entity for DALI Device objects.""" + + def __init__(self, device: Device) -> None: + """Initialize device entity.""" + super().__init__(device) + self._attr_available = device.status == "online" diff --git a/homeassistant/components/sunricher_dali/light.py b/homeassistant/components/sunricher_dali/light.py index 47774bc1ac8..43079505c26 100644 --- a/homeassistant/components/sunricher_dali/light.py +++ b/homeassistant/components/sunricher_dali/light.py @@ -22,6 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .entity import DaliDeviceEntity from .types import DaliCenterConfigEntry _LOGGER = logging.getLogger(__name__) @@ -45,10 +46,9 @@ async def async_setup_entry( ) -class DaliCenterLight(LightEntity): +class DaliCenterLight(DaliDeviceEntity, LightEntity): """Representation of a Sunricher DALI Light.""" - _attr_has_entity_name = True _attr_name = None _attr_is_on: bool | None = None _attr_brightness: int | None = None @@ -60,11 +60,8 @@ class DaliCenterLight(LightEntity): def __init__(self, light: Device) -> None: """Initialize the light entity.""" - + super().__init__(light) self._light = light - self._unavailable_logged = False - self._attr_unique_id = light.unique_id - self._attr_available = light.status == "online" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, light.dev_id)}, name=light.name, @@ -111,6 +108,7 @@ class DaliCenterLight(LightEntity): async def async_added_to_hass(self) -> None: """Handle entity addition to Home Assistant.""" + await super().async_added_to_hass() self.async_on_remove( self._light.register_listener( @@ -118,27 +116,10 @@ class DaliCenterLight(LightEntity): ) ) - self.async_on_remove( - self._light.register_listener( - CallbackEventType.ONLINE_STATUS, self._handle_availability - ) - ) - # read_status() only queues a request on the gateway and relies on the # current event loop via call_later, so it must run in the loop thread. self._light.read_status() - @callback - def _handle_availability(self, available: bool) -> None: - self._attr_available = available - if not available and not self._unavailable_logged: - _LOGGER.info("Light %s became unavailable", self._attr_unique_id) - self._unavailable_logged = True - elif available and self._unavailable_logged: - _LOGGER.info("Light %s is back online", self._attr_unique_id) - self._unavailable_logged = False - self.schedule_update_ha_state() - @callback def _handle_device_update(self, status: LightStatus) -> None: if status.get("is_on") is not None: diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index 7cbfa14457a..2fa4b6c8b47 100644 --- a/homeassistant/components/sunricher_dali/manifest.json +++ b/homeassistant/components/sunricher_dali/manifest.json @@ -3,8 +3,13 @@ "name": "Sunricher DALI", "codeowners": ["@niracler"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/sunricher_dali", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["PySrDaliGateway==0.16.2"] + "requirements": ["PySrDaliGateway==0.18.0"] } diff --git a/homeassistant/components/sunricher_dali/quality_scale.yaml b/homeassistant/components/sunricher_dali/quality_scale.yaml index 633fb6bc239..4bdc09143bf 100644 --- a/homeassistant/components/sunricher_dali/quality_scale.yaml +++ b/homeassistant/components/sunricher_dali/quality_scale.yaml @@ -40,8 +40,10 @@ rules: # Gold devices: done diagnostics: todo - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: + status: exempt + comment: Device has no way to be discovered. docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/sunricher_dali/scene.py b/homeassistant/components/sunricher_dali/scene.py new file mode 100644 index 00000000000..aef1892c5b3 --- /dev/null +++ b/homeassistant/components/sunricher_dali/scene.py @@ -0,0 +1,45 @@ +"""Support for DALI Center Scene entities.""" + +import logging +from typing import Any + +from PySrDaliGateway import Scene + +from homeassistant.components.scene import Scene as SceneEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .entity import DaliCenterEntity +from .types import DaliCenterConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DaliCenterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up DALI Center scene entities from config entry.""" + async_add_entities(DaliCenterScene(scene) for scene in entry.runtime_data.scenes) + + +class DaliCenterScene(DaliCenterEntity, SceneEntity): + """Representation of a DALI Center Scene.""" + + def __init__(self, scene: Scene) -> None: + """Initialize the DALI scene.""" + super().__init__(scene) + self._scene = scene + self._attr_name = scene.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, scene.gw_sn)}, + ) + + async def async_activate(self, **kwargs: Any) -> None: + """Activate the DALI scene.""" + await self.hass.async_add_executor_job(self._scene.activate) diff --git a/homeassistant/components/sunricher_dali/strings.json b/homeassistant/components/sunricher_dali/strings.json index aec4f1d493f..5a2eccf42b2 100644 --- a/homeassistant/components/sunricher_dali/strings.json +++ b/homeassistant/components/sunricher_dali/strings.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_dhcp_flow": "No DHCP flow" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_failed": "Failed to discover Sunricher DALI gateways on the network", - "no_devices_found": "No Sunricher DALI gateways found on the network", - "unknown": "[%key:common::config_flow::error::unknown%]" + "no_devices_found": "No Sunricher DALI gateways found on the network" }, "step": { "select_gateway": { diff --git a/homeassistant/components/sunricher_dali/types.py b/homeassistant/components/sunricher_dali/types.py index 39dacb69a6c..f93b192de64 100644 --- a/homeassistant/components/sunricher_dali/types.py +++ b/homeassistant/components/sunricher_dali/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from PySrDaliGateway import DaliGateway, Device +from PySrDaliGateway import DaliGateway, Device, Scene from homeassistant.config_entries import ConfigEntry @@ -13,6 +13,7 @@ class DaliCenterData: gateway: DaliGateway devices: list[Device] + scenes: list[Scene] type DaliCenterConfigEntry = ConfigEntry[DaliCenterData] diff --git a/homeassistant/components/switch/icons.json b/homeassistant/components/switch/icons.json index de17079d8ed..694acdfba58 100644 --- a/homeassistant/components/switch/icons.json +++ b/homeassistant/components/switch/icons.json @@ -29,5 +29,13 @@ "turn_on": { "service": "mdi:toggle-switch-variant" } + }, + "triggers": { + "turned_off": { + "trigger": "mdi:toggle-switch-variant-off" + }, + "turned_on": { + "trigger": "mdi:toggle-switch-variant" + } } } diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 198ad255e3e..7343fda5240 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted switches to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "action_type": { "toggle": "[%key:common::device_automation::action_type::toggle%]", @@ -41,6 +45,15 @@ } } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "toggle": { "description": "Toggles a switch on/off.", @@ -55,5 +68,27 @@ "name": "[%key:common::action::turn_on%]" } }, - "title": "Switch" + "title": "Switch", + "triggers": { + "turned_off": { + "description": "Triggers after one or more switches turn off.", + "fields": { + "behavior": { + "description": "[%key:component::switch::common::trigger_behavior_description%]", + "name": "[%key:component::switch::common::trigger_behavior_name%]" + } + }, + "name": "Switch turned off" + }, + "turned_on": { + "description": "Triggers after one or more switches turn on.", + "fields": { + "behavior": { + "description": "[%key:component::switch::common::trigger_behavior_description%]", + "name": "[%key:component::switch::common::trigger_behavior_name%]" + } + }, + "name": "Switch turned on" + } + } } diff --git a/homeassistant/components/switch/trigger.py b/homeassistant/components/switch/trigger.py new file mode 100644 index 00000000000..6797cad0f6b --- /dev/null +++ b/homeassistant/components/switch/trigger.py @@ -0,0 +1,17 @@ +"""Provides triggers for switch platform.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +from .const import DOMAIN + +TRIGGERS: dict[str, type[Trigger]] = { + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for switch platform.""" + return TRIGGERS diff --git a/homeassistant/components/switch/triggers.yaml b/homeassistant/components/switch/triggers.yaml new file mode 100644 index 00000000000..dc9e3401867 --- /dev/null +++ b/homeassistant/components/switch/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: switch + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +turned_off: *trigger_common +turned_on: *trigger_common diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 5c856bc216c..3941c1cc500 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -17,9 +17,7 @@ from switchbot import ( import voluptuous as vol from homeassistant.components.bluetooth import ( - BluetoothScanningMode, BluetoothServiceInfoBleak, - async_current_scanners, async_discovered_service_info, ) from homeassistant.config_entries import ( @@ -325,15 +323,6 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step to choose cloud login or direct discovery.""" - # Check if all scanners are in active mode - # If so, skip the menu and go directly to device selection - scanners = async_current_scanners(self.hass) - if scanners and all( - scanner.current_mode == BluetoothScanningMode.ACTIVE for scanner in scanners - ): - # All scanners are active, skip the menu - return await self.async_step_select_device() - return self.async_show_menu( step_id="user", menu_options=["cloud_login", "select_device"], diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 2a892206d8f..316badc42f7 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -147,16 +147,16 @@ class SwitchBotCloudBinarySensor(SwitchBotCloudEntity, BinarySensorEntity): self.entity_description = description self._attr_unique_id = f"{device.device_id}_{description.key}" - @property - def is_on(self) -> bool | None: + def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if not self.coordinator.data: - return None + return if self.entity_description.value_fn: - return self.entity_description.value_fn(self.coordinator.data) + self._attr_is_on = self.entity_description.value_fn(self.coordinator.data) + return - return ( + self._attr_is_on = ( self.coordinator.data.get(self.entity_description.key) == self.entity_description.on_value ) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 574c5399ff8..c057ae0c214 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -13,12 +13,12 @@ from systembridgeconnector.exceptions import ( ConnectionErrorException, DataMissingException, ) +from systembridgeconnector.models.keyboard_key import KeyboardKey +from systembridgeconnector.models.keyboard_text import KeyboardText +from systembridgeconnector.models.modules.processes import Process +from systembridgeconnector.models.open_path import OpenPath +from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import Version -from systembridgemodels.keyboard_key import KeyboardKey -from systembridgemodels.keyboard_text import KeyboardText -from systembridgemodels.modules.processes import Process -from systembridgemodels.open_path import OpenPath -from systembridgemodels.open_url import OpenUrl import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index bf6057a27bb..6bf001c9603 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -12,8 +12,8 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) +from systembridgeconnector.models.modules import GetData, Module from systembridgeconnector.websocket_client import WebSocketClient -from systembridgemodels.modules import GetData, Module import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 235d7e6b986..ae25f80f455 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -2,7 +2,7 @@ from typing import Final -from systembridgemodels.modules import Module +from systembridgeconnector.models.modules import Module DOMAIN = "system_bridge" diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index f665c88121c..6fca2e5902f 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -13,13 +13,13 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.websocket_client import WebSocketClient -from systembridgemodels.modules import ( +from systembridgeconnector.models.modules import ( GetData, Module, ModulesData, RegisterDataListener, ) +from systembridgeconnector.websocket_client import WebSocketClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/system_bridge/data.py b/homeassistant/components/system_bridge/data.py index f07e8d75f28..983b16a20d4 100644 --- a/homeassistant/components/system_bridge/data.py +++ b/homeassistant/components/system_bridge/data.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field -from systembridgemodels.modules import ( +from systembridgeconnector.models.modules import ( CPU, GPU, Battery, diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index d2d9bb6e657..cefd5c3520f 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "requirements": ["systembridgeconnector==5.1.0"], + "requirements": ["systembridgeconnector==5.2.4"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index 2be2f06c1e7..c7b1fab679a 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime as dt from typing import Final -from systembridgemodels.media_control import MediaAction, MediaControl +from systembridgeconnector.models.media_control import MediaAction, MediaControl from homeassistant.components.media_player import ( MediaPlayerDeviceClass, diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index 53bc4f32506..930557568b8 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -2,9 +2,9 @@ from __future__ import annotations -from systembridgemodels.media_directories import MediaDirectory -from systembridgemodels.media_files import MediaFile, MediaFiles -from systembridgemodels.media_get_files import MediaGetFiles +from systembridgeconnector.models.media_directories import MediaDirectory +from systembridgeconnector.models.media_files import MediaFile, MediaFiles +from systembridgeconnector.models.media_get_files import MediaGetFiles from homeassistant.components.media_player import MediaClass from homeassistant.components.media_source import ( @@ -183,9 +183,9 @@ def _build_media_items( for file in media_files.files if file.is_directory or ( - file.is_file - and file.mime_type is not None - and file.mime_type.startswith(MEDIA_MIME_TYPES) + not file.is_directory + and file.content_type is not None + and file.content_type.startswith(MEDIA_MIME_TYPES) ) ], ) @@ -197,20 +197,20 @@ def _build_media_item( ) -> BrowseMediaSource: """Build individual media item.""" ext = "" - if media_file.is_file and media_file.mime_type is not None: - ext = f"~~{media_file.mime_type}" + if not media_file.is_directory and media_file.content_type is not None: + ext = f"~~{media_file.content_type}" - if media_file.is_directory or media_file.mime_type is None: + if media_file.is_directory or media_file.content_type is None: media_class = MediaClass.DIRECTORY else: - media_class = MEDIA_CLASS_MAP[media_file.mime_type.split("/", 1)[0]] + media_class = MEDIA_CLASS_MAP[media_file.content_type.split("/", 1)[0]] return BrowseMediaSource( domain=DOMAIN, identifier=f"{path}/{media_file.name}{ext}", media_class=media_class, - media_content_type=media_file.mime_type, + media_content_type=media_file.content_type, title=media_file.name, - can_play=media_file.is_file, + can_play=not media_file.is_directory, can_expand=media_file.is_directory, ) diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index 0e2f058cc7c..2b13fef071e 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from systembridgemodels.notification import Notification +from systembridgeconnector.models.notification import Notification from homeassistant.components.notify import ( ATTR_DATA, diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index f07c96fe8ca..7a7f2c555df 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -7,9 +7,9 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Final, cast -from systembridgemodels.modules.cpu import PerCPU -from systembridgemodels.modules.displays import Display -from systembridgemodels.modules.gpus import GPU +from systembridgeconnector.models.modules.cpu import PerCPU +from systembridgeconnector.models.modules.displays import Display +from systembridgeconnector.models.modules.gpus import GPU from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 629d7b2ad59..943efe00751 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -2,24 +2,22 @@ from __future__ import annotations -from ipaddress import IPv4Network, ip_network import logging from types import ModuleType -from typing import Any from telegram import Bot from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.const import ( + ATTR_DOMAIN, + ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, - CONF_API_KEY, + ATTR_SERVICE, CONF_PLATFORM, - CONF_SOURCE, - CONF_URL, Platform, ) from homeassistant.core import ( @@ -34,8 +32,8 @@ from homeassistant.exceptions import ( HomeAssistantError, ServiceValidationError, ) -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import broadcast, polling, webhooks from .bot import TelegramBotConfigEntry, TelegramNotificationService, initialize_bot @@ -65,6 +63,7 @@ from .const import ( ATTR_PASSWORD, ATTR_QUESTION, ATTR_REACTION, + ATTR_REPLY_TO_MSGID, ATTR_RESIZE_KEYBOARD, ATTR_SHOW_ALERT, ATTR_STICKER_ID, @@ -85,14 +84,8 @@ from .const import ( CHAT_ACTION_UPLOAD_VIDEO, CHAT_ACTION_UPLOAD_VIDEO_NOTE, CHAT_ACTION_UPLOAD_VOICE, - CONF_ALLOWED_CHAT_IDS, - CONF_BOT_COUNT, CONF_CONFIG_ENTRY_ID, - CONF_PROXY_URL, - CONF_TRUSTED_NETWORKS, - DEFAULT_TRUSTED_NETWORKS, DOMAIN, - PARSER_MD, PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, @@ -118,34 +111,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_PLATFORM): vol.In( - (PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS) - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_ALLOWED_CHAT_IDS): vol.All( - cv.ensure_list, [vol.Coerce(int)] - ), - vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string, - vol.Optional(CONF_PROXY_URL): cv.string, - # webhooks - vol.Optional(CONF_URL): cv.url, - vol.Optional( - CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS - ): vol.All(cv.ensure_list, [ip_network]), - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) BASE_SERVICE_SCHEMA = vol.Schema( { @@ -161,35 +127,47 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_TIMEOUT): cv.positive_int, vol.Optional(ATTR_MESSAGE_TAG): cv.string, vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - }, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_TITLE): cv.string} -) - -SERVICE_SCHEMA_SEND_CHAT_ACTION = BASE_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_CHAT_ACTION): vol.In( - ( - CHAT_ACTION_TYPING, - CHAT_ACTION_UPLOAD_PHOTO, - CHAT_ACTION_RECORD_VIDEO, - CHAT_ACTION_UPLOAD_VIDEO, - CHAT_ACTION_RECORD_VOICE, - CHAT_ACTION_UPLOAD_VOICE, - CHAT_ACTION_UPLOAD_DOCUMENT, - CHAT_ACTION_CHOOSE_STICKER, - CHAT_ACTION_FIND_LOCATION, - CHAT_ACTION_RECORD_VIDEO_NOTE, - CHAT_ACTION_UPLOAD_VIDEO_NOTE, - ) - ), } ) -SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend( +SERVICE_SCHEMA_SEND_MESSAGE = vol.All( + cv.deprecated(ATTR_TIMEOUT), + BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_TITLE): cv.string, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } + ), +) + +SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_CHAT_ACTION): vol.In( + ( + CHAT_ACTION_TYPING, + CHAT_ACTION_UPLOAD_PHOTO, + CHAT_ACTION_RECORD_VIDEO, + CHAT_ACTION_UPLOAD_VIDEO, + CHAT_ACTION_RECORD_VOICE, + CHAT_ACTION_UPLOAD_VOICE, + CHAT_ACTION_UPLOAD_DOCUMENT, + CHAT_ACTION_CHOOSE_STICKER, + CHAT_ACTION_FIND_LOCATION, + CHAT_ACTION_RECORD_VIDEO_NOTE, + CHAT_ACTION_UPLOAD_VIDEO_NOTE, + ) + ), + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + } + ), +) + +SERVICE_SCHEMA_BASE_SEND_FILE = BASE_SERVICE_SCHEMA.extend( { vol.Optional(ATTR_URL): cv.string, vol.Optional(ATTR_FILE): cv.string, @@ -198,71 +176,95 @@ SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend( vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_AUTHENTICATION): cv.string, vol.Optional(ATTR_VERIFY_SSL): cv.boolean, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), } ) -SERVICE_SCHEMA_SEND_STICKER = SERVICE_SCHEMA_SEND_FILE.extend( - {vol.Optional(ATTR_STICKER_ID): cv.string} +SERVICE_SCHEMA_SEND_FILE = vol.All( + cv.deprecated(ATTR_TIMEOUT), SERVICE_SCHEMA_BASE_SEND_FILE ) -SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_LONGITUDE): cv.string, - vol.Required(ATTR_LATITUDE): cv.string, - } + +SERVICE_SCHEMA_SEND_STICKER = vol.All( + cv.deprecated(ATTR_TIMEOUT), + SERVICE_SCHEMA_BASE_SEND_FILE.extend({vol.Optional(ATTR_STICKER_ID): cv.string}), ) -SERVICE_SCHEMA_SEND_POLL = vol.Schema( - { - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Required(ATTR_QUESTION): cv.string, - vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_OPEN_PERIOD): cv.positive_int, - vol.Optional(ATTR_IS_ANONYMOUS, default=True): cv.boolean, - vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, - vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, - vol.Optional(ATTR_TIMEOUT): cv.positive_int, - vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - } +SERVICE_SCHEMA_SEND_LOCATION = vol.All( + cv.deprecated(ATTR_TIMEOUT), + BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_LONGITUDE): cv.string, + vol.Required(ATTR_LATITUDE): cv.string, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } + ), ) -SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend( - { - vol.Required(ATTR_MESSAGEID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Required(ATTR_CHAT_ID): vol.Coerce(int), - } +SERVICE_SCHEMA_SEND_POLL = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_QUESTION): cv.string, + vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_OPEN_PERIOD): cv.positive_int, + vol.Optional(ATTR_IS_ANONYMOUS, default=True): cv.boolean, + vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } + ), ) -SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.Schema( - { - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGEID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Required(ATTR_CHAT_ID): vol.Coerce(int), - vol.Optional(ATTR_TIMEOUT): cv.positive_int, - vol.Optional(ATTR_CAPTION): cv.string, - vol.Required(ATTR_MEDIA_TYPE): vol.In( - ( - str(InputMediaType.ANIMATION), - str(InputMediaType.AUDIO), - str(InputMediaType.VIDEO), - str(InputMediaType.DOCUMENT), - str(InputMediaType.PHOTO), - ) - ), - vol.Optional(ATTR_URL): cv.string, - vol.Optional(ATTR_FILE): cv.string, - vol.Optional(ATTR_USERNAME): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_AUTHENTICATION): cv.string, - vol.Optional(ATTR_VERIFY_SSL): cv.boolean, - vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - }, - extra=vol.ALLOW_EXTRA, +SERVICE_SCHEMA_EDIT_MESSAGE = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TITLE): cv.string, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Required(ATTR_MESSAGEID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Optional(ATTR_PARSER): cv.string, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + } + ), +) + +SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGEID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Optional(ATTR_CAPTION): cv.string, + vol.Required(ATTR_MEDIA_TYPE): vol.In( + ( + str(InputMediaType.ANIMATION), + str(InputMediaType.AUDIO), + str(InputMediaType.VIDEO), + str(InputMediaType.DOCUMENT), + str(InputMediaType.PHOTO), + ) + ), + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_AUTHENTICATION): cv.string, + vol.Optional(ATTR_VERIFY_SSL): cv.boolean, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + } + ), ) SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema( @@ -274,8 +276,7 @@ SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema( vol.Required(ATTR_CHAT_ID): vol.Coerce(int), vol.Required(ATTR_CAPTION): cv.string, vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - }, - extra=vol.ALLOW_EXTRA, + } ) SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema( @@ -286,8 +287,7 @@ SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema( ), vol.Required(ATTR_CHAT_ID): vol.Coerce(int), vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, - }, - extra=vol.ALLOW_EXTRA, + } ) SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema( @@ -296,8 +296,7 @@ SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema( vol.Required(ATTR_MESSAGE): cv.string, vol.Required(ATTR_CALLBACK_QUERY_ID): vol.Coerce(int), vol.Optional(ATTR_SHOW_ALERT): cv.boolean, - }, - extra=vol.ALLOW_EXTRA, + } ) SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema( @@ -307,8 +306,7 @@ SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema( vol.Required(ATTR_MESSAGEID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), - }, - extra=vol.ALLOW_EXTRA, + } ) SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema( @@ -327,11 +325,10 @@ SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( vol.Required(ATTR_CHAT_ID): vol.Coerce(int), vol.Required(ATTR_REACTION): cv.string, vol.Optional(ATTR_IS_BIG, default=False): cv.boolean, - }, - extra=vol.ALLOW_EXTRA, + } ) -SERVICE_MAP = { +SERVICE_MAP: dict[str, VolSchemaType] = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_CHAT_ACTION: SERVICE_SCHEMA_SEND_CHAT_ACTION, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, @@ -365,34 +362,6 @@ PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Telegram bot component.""" - # import the last YAML config since existing behavior only works with the last config - domain_config: list[dict[str, Any]] | None = config.get(DOMAIN) - if domain_config: - trusted_networks: list[IPv4Network] = domain_config[-1].get( - CONF_TRUSTED_NETWORKS, [] - ) - trusted_networks_str: list[str] = ( - [str(trusted_network) for trusted_network in trusted_networks] - if trusted_networks - else [] - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_IMPORT}, - data={ - CONF_PLATFORM: domain_config[-1][CONF_PLATFORM], - CONF_API_KEY: domain_config[-1][CONF_API_KEY], - CONF_ALLOWED_CHAT_IDS: domain_config[-1][CONF_ALLOWED_CHAT_IDS], - ATTR_PARSER: domain_config[-1][ATTR_PARSER], - CONF_PROXY_URL: domain_config[-1].get(CONF_PROXY_URL), - CONF_URL: domain_config[-1].get(CONF_URL), - CONF_TRUSTED_NETWORKS: trusted_networks_str, - CONF_BOT_COUNT: len(domain_config), - }, - ) - ) - async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse: """Handle sending Telegram Bot message service calls.""" @@ -400,6 +369,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: kwargs = dict(service.data) _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) + if ATTR_TIMEOUT in service.data: + _deprecate_timeout(hass, service) + config_entry_id: str | None = service.data.get(CONF_CONFIG_ENTRY_ID) config_entry: TelegramBotConfigEntry | None = None if config_entry_id: @@ -532,6 +504,36 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _deprecate_timeout(hass: HomeAssistant, service: ServiceCall) -> None: + # default: service was called using frontend such as developer tools or automation editor + service_call_origin = "call_service" + + origin = service.context.origin_event + if origin and ATTR_ENTITY_ID in origin.data: + # automation + service_call_origin = origin.data[ATTR_ENTITY_ID] + elif origin and origin.data.get(ATTR_DOMAIN) == SCRIPT_DOMAIN: + # script + service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}" + + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_timeout_parameter", + breaks_in_ha_version="2026.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_timeout_parameter", + translation_placeholders={ + "integration_title": "Telegram Bot", + "action": f"{DOMAIN}.{service.service}", + "action_origin": service_call_origin, + }, + learn_more_url="https://github.com/home-assistant/core/pull/155198", + ) + + async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool: """Create the Telegram bot from config entry.""" bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index fcf8dea3643..325aa7ffbc6 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -448,7 +448,7 @@ class TelegramNotificationService: params[ATTR_MESSAGE_THREAD_ID] = data[ATTR_MESSAGE_THREAD_ID] # Keyboards: if ATTR_KEYBOARD in data: - keys = data.get(ATTR_KEYBOARD) + keys = data[ATTR_KEYBOARD] keys = keys if isinstance(keys, list) else [keys] if keys: params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( @@ -783,6 +783,7 @@ class TelegramNotificationService: None, chat_id=chat_id, action=chat_action, + message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID), context=context, ) result[chat_id] = is_successful diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 3adc0dd552f..343a8efdd5e 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -11,20 +11,17 @@ from telegram.error import BadRequest, InvalidToken, TelegramError import voluptuous as vol from homeassistant.config_entries import ( - SOURCE_IMPORT, SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - ConfigSubentryData, ConfigSubentryFlow, OptionsFlow, SubentryFlowResult, ) from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow, section +from homeassistant.data_entry_flow import section from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.selector import ( SelectSelector, @@ -39,8 +36,6 @@ from .bot import TelegramBotConfigEntry from .const import ( ATTR_PARSER, BOT_NAME, - CONF_ALLOWED_CHAT_IDS, - CONF_BOT_COUNT, CONF_CHAT_ID, CONF_PROXY_URL, CONF_TRUSTED_NETWORKS, @@ -48,9 +43,6 @@ from .const import ( DOMAIN, ERROR_FIELD, ERROR_MESSAGE, - ISSUE_DEPRECATED_YAML, - ISSUE_DEPRECATED_YAML_HAS_MORE_PLATFORMS, - ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR, PARSER_HTML, PARSER_MD, PARSER_MD2, @@ -67,6 +59,8 @@ _LOGGER = logging.getLogger(__name__) DESCRIPTION_PLACEHOLDERS: dict[str, str] = { "botfather_username": "@BotFather", "botfather_url": "https://t.me/botfather", + "getidsbot_username": "@GetIDs Bot", + "getidsbot_url": "https://t.me/getidsbot", "socks_url": "socks5://username:password@proxy_ip:proxy_port", } @@ -206,111 +200,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # for passing data between steps self._step_user_data: dict[str, Any] = {} - # triggered by async_setup() from __init__.py - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle import of config entry from configuration.yaml.""" - - telegram_bot: str = f"{import_data[CONF_PLATFORM]} Telegram bot" - bot_count: int = import_data[CONF_BOT_COUNT] - - import_data[CONF_TRUSTED_NETWORKS] = ",".join( - import_data[CONF_TRUSTED_NETWORKS] - ) - import_data[SECTION_ADVANCED_SETTINGS] = { - CONF_PROXY_URL: import_data.get(CONF_PROXY_URL) - } - try: - config_flow_result: ConfigFlowResult = await self.async_step_user( - import_data - ) - except AbortFlow: - # this happens if the config entry is already imported - self._create_issue(ISSUE_DEPRECATED_YAML, telegram_bot, bot_count) - raise - else: - errors: dict[str, str] | None = config_flow_result.get("errors") - if errors: - error: str = errors.get("base", "unknown") - self._create_issue( - error, - telegram_bot, - bot_count, - config_flow_result["description_placeholders"], - ) - return self.async_abort(reason="import_failed") - - subentries: list[ConfigSubentryData] = [] - allowed_chat_ids: list[int] = import_data[CONF_ALLOWED_CHAT_IDS] - assert self._bot is not None, "Bot should be initialized during import" - for chat_id in allowed_chat_ids: - chat_name: str = await _async_get_chat_name(self._bot, chat_id) - subentry: ConfigSubentryData = ConfigSubentryData( - data={CONF_CHAT_ID: chat_id}, - subentry_type=CONF_ALLOWED_CHAT_IDS, - title=f"{chat_name} ({chat_id})", - unique_id=str(chat_id), - ) - subentries.append(subentry) - config_flow_result["subentries"] = subentries - - self._create_issue( - ISSUE_DEPRECATED_YAML, - telegram_bot, - bot_count, - config_flow_result["description_placeholders"], - ) - return config_flow_result - - def _create_issue( - self, - issue: str, - telegram_bot_type: str, - bot_count: int, - description_placeholders: Mapping[str, str] | None = None, - ) -> None: - translation_key: str = ( - ISSUE_DEPRECATED_YAML - if bot_count == 1 - else ISSUE_DEPRECATED_YAML_HAS_MORE_PLATFORMS - ) - if issue != ISSUE_DEPRECATED_YAML: - translation_key = ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR - - telegram_bot = ( - description_placeholders.get(BOT_NAME, telegram_bot_type) - if description_placeholders - else telegram_bot_type - ) - error_field = ( - description_placeholders.get(ERROR_FIELD, "Unknown error") - if description_placeholders - else "Unknown error" - ) - error_message = ( - description_placeholders.get(ERROR_MESSAGE, "Unknown error") - if description_placeholders - else "Unknown error" - ) - - async_create_issue( - self.hass, - DOMAIN, - ISSUE_DEPRECATED_YAML, - breaks_in_ha_version="2025.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Telegram Bot", - "telegram_bot": telegram_bot, - ERROR_FIELD: error_field, - ERROR_MESSAGE: error_message, - }, - learn_more_url="https://github.com/home-assistant/core/pull/144617", - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -359,23 +248,13 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PROXY_URL ), }, - options={ - # this value may come from yaml import - ATTR_PARSER: user_input.get(ATTR_PARSER, PARSER_MD) - }, + options={ATTR_PARSER: PARSER_MD}, description_placeholders=description_placeholders, ) self._bot_name = bot_name self._step_user_data.update(user_input) - if self.source == SOURCE_IMPORT: - return await self.async_step_webhooks( - { - CONF_URL: user_input.get(CONF_URL), - CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS], - } - ) return await self.async_step_webhooks() async def _shutdown_bot(self) -> None: @@ -665,6 +544,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): return self.async_show_form( step_id="user", data_schema=vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}), + description_placeholders=DESCRIPTION_PLACEHOLDERS, errors=errors, ) diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index 7dff2ab3169..a92661bdf42 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -10,7 +10,6 @@ PLATFORM_WEBHOOKS = "webhooks" SECTION_ADVANCED_SETTINGS = "advanced_settings" SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids" -CONF_BOT_COUNT = "bot_count" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_CONFIG_ENTRY_ID = "config_entry_id" @@ -24,12 +23,6 @@ BOT_NAME = "telegram_bot" ERROR_FIELD = "error_field" ERROR_MESSAGE = "error_message" -ISSUE_DEPRECATED_YAML = "deprecated_yaml" -ISSUE_DEPRECATED_YAML_HAS_MORE_PLATFORMS = ( - "deprecated_yaml_import_issue_has_more_platforms" -) -ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR = "deprecated_yaml_import_issue_error" - DEFAULT_TRUSTED_NETWORKS = [ip_network("149.154.160.0/20"), ip_network("91.108.4.0/22")] SERVICE_SEND_CHAT_ACTION = "send_chat_action" diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 5da096c3e33..e0480b8bfdc 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -35,12 +35,6 @@ send_message: disable_web_page_preview: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -157,12 +151,6 @@ send_photo: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -237,12 +225,6 @@ send_sticker: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -326,12 +308,6 @@ send_animation: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -415,12 +391,6 @@ send_video: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -495,12 +465,6 @@ send_voice: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -584,12 +548,6 @@ send_document: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -644,12 +602,6 @@ send_location: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -711,12 +663,6 @@ send_poll: disable_notification: selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds reply_to_message_id: selector: number: @@ -785,12 +731,6 @@ edit_message_media: example: 12345 selector: text: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds media_type: selector: select: @@ -916,12 +856,6 @@ answer_callback_query: required: true selector: boolean: - timeout: - selector: - number: - min: 1 - max: 3600 - unit_of_measurement: seconds delete_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 5582191b06e..6c6d3a8c8e7 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -99,6 +99,7 @@ "data_description": { "chat_id": "ID representing the user or group chat to which messages can be sent." }, + "description": "To get your chat ID, follow these steps:\n\n1. Open Telegram and start a chat with [{getidsbot_username}]({getidsbot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `id` field of the bot's response.", "title": "Add chat" } } @@ -209,17 +210,16 @@ } }, "issues": { - "deprecated_yaml": { - "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", - "title": "The {integration_title} YAML configuration is being removed" - }, - "deprecated_yaml_import_issue_error": { - "description": "Configuring {integration_title} using YAML is being removed but there was an error while importing your existing configuration ({telegram_bot}): {error_message}.\nSetup will not proceed.\n\nVerify that your {telegram_bot} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually.", - "title": "YAML import failed due to invalid {error_field}" - }, - "deprecated_yaml_import_issue_has_more_platforms": { - "description": "Configuring {integration_title} using YAML is being removed.\n\nThe last entry of your existing YAML configuration ({telegram_bot}) has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue. The other Telegram bots will need to be configured manually in the UI.", - "title": "The {integration_title} YAML configuration is being removed" + "deprecated_timeout_parameter": { + "fix_flow": { + "step": { + "confirm": { + "description": "Update all affected automations and scripts to remove the `timeout` parameter and then click SUBMIT to fix this issue.\n\nThe deprecated parameter was last seen in the `{action}` action originating from `{action_origin}`.", + "title": "The `timeout` parameter for {integration_title} is being removed" + } + } + }, + "title": "The `timeout` parameter for {integration_title} is being removed" } }, "options": { @@ -302,10 +302,6 @@ "show_alert": { "description": "Show a permanent notification.", "name": "Show alert" - }, - "timeout": { - "description": "Timeout for sending the answer in seconds.", - "name": "Read timeout" } }, "name": "Answer callback query" @@ -431,10 +427,6 @@ "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]", "name": "[%key:common::config_flow::data::password%]" }, - "timeout": { - "description": "Timeout for sending the media in seconds.", - "name": "[%key:component::telegram_bot::services::send_photo::fields::timeout::name%]" - }, "url": { "description": "Remote path to the media.", "name": "[%key:common::config_flow::data::url%]" @@ -546,10 +538,6 @@ "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]", "name": "Target" }, - "timeout": { - "description": "[%key:component::telegram_bot::services::send_sticker::fields::timeout::description%]", - "name": "Read timeout" - }, "url": { "description": "Remote path to a GIF or H.264/MPEG-4 AVC video without sound.", "name": "[%key:common::config_flow::data::url%]" @@ -647,10 +635,6 @@ "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]", "name": "Target" }, - "timeout": { - "description": "Timeout for sending the document in seconds.", - "name": "Read timeout" - }, "url": { "description": "Remote path to a document.", "name": "[%key:common::config_flow::data::url%]" @@ -713,10 +697,6 @@ "target": { "description": "An array of pre-authorized chat IDs to send the location to. If not present, first allowed chat ID is the default.", "name": "Target" - }, - "timeout": { - "description": "[%key:component::telegram_bot::services::send_photo::fields::timeout::description%]", - "name": "Read timeout" } }, "name": "Send location" @@ -768,10 +748,6 @@ "description": "An array of pre-authorized chat IDs to send the notification to. If not present, first allowed chat ID is the default.", "name": "Target" }, - "timeout": { - "description": "Timeout for sending the message in seconds. Will help with timeout errors (poor Internet connection, etc).", - "name": "Read timeout" - }, "title": { "description": "Optional title for your notification. Will be composed as '%title\\n%message'.", "name": "Title" @@ -834,10 +810,6 @@ "description": "An array of pre-authorized chat IDs to send the document to. If not present, first allowed chat ID is the default.", "name": "Target" }, - "timeout": { - "description": "Timeout for sending the photo in seconds.", - "name": "Read timeout" - }, "url": { "description": "Remote path to an image.", "name": "[%key:common::config_flow::data::url%]" @@ -900,10 +872,6 @@ "target": { "description": "[%key:component::telegram_bot::services::send_location::fields::target::description%]", "name": "Target" - }, - "timeout": { - "description": "Timeout for sending the poll in seconds.", - "name": "Read timeout" } }, "name": "Send poll" @@ -959,10 +927,6 @@ "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]", "name": "Target" }, - "timeout": { - "description": "Timeout for sending the sticker in seconds.", - "name": "Read timeout" - }, "url": { "description": "Remote path to a static .webp or animated .tgs sticker.", "name": "[%key:common::config_flow::data::url%]" @@ -1038,10 +1002,6 @@ "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]", "name": "Target" }, - "timeout": { - "description": "Timeout for sending the video in seconds.", - "name": "Read timeout" - }, "url": { "description": "Remote path to a video.", "name": "[%key:common::config_flow::data::url%]" @@ -1113,10 +1073,6 @@ "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]", "name": "Target" }, - "timeout": { - "description": "Timeout for sending the voice in seconds.", - "name": "Read timeout" - }, "url": { "description": "Remote path to a voice message.", "name": "[%key:common::config_flow::data::url%]" diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 3e14e5ad2b2..38a5900fcc1 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -1,6 +1,5 @@ """Tesla Fleet integration.""" -import asyncio from typing import Final from aiohttp.client_exceptions import ClientResponseError @@ -83,29 +82,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - region: str = token["ou_code"].lower() oauth_session = OAuth2Session(hass, entry, implementation) - refresh_lock = asyncio.Lock() - async def _refresh_token() -> str: - async with refresh_lock: - try: - await oauth_session.async_ensure_token_valid() - except ClientResponseError as e: - if e.status == 401: - raise ConfigEntryAuthFailed from e - raise ConfigEntryNotReady from e - token: str = oauth_session.token[CONF_ACCESS_TOKEN] - return token + async def _get_access_token() -> str: + try: + await oauth_session.async_ensure_token_valid() + except ClientResponseError as e: + if e.status == 401: + raise ConfigEntryAuthFailed from e + raise ConfigEntryNotReady from e + token: str = oauth_session.token[CONF_ACCESS_TOKEN] + return token # Create API connection tesla = TeslaFleetApi( session=session, - access_token=access_token, + access_token=_get_access_token, region=region, charging_scope=False, partner_scope=False, energy_scope=Scope.ENERGY_DEVICE_DATA in scopes, vehicle_scope=Scope.VEHICLE_DEVICE_DATA in scopes, - refresh_hook=_refresh_token, ) try: products = (await tesla.products())["response"] diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index f53d2104df4..b05722af3e1 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.5"] + "requirements": ["tesla-fleet-api==1.3.0"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 6883d37e0c6..bbd196217c1 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.5", "teslemetry-stream==0.7.10"] + "requirements": ["tesla-fleet-api==1.3.0", "teslemetry-stream==0.8.2"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index f016bfae9d0..2025c9c2eae 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.5"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.3.0"] } diff --git a/homeassistant/components/text/trigger.py b/homeassistant/components/text/trigger.py index 932d4572138..d662a8c978c 100644 --- a/homeassistant/components/text/trigger.py +++ b/homeassistant/components/text/trigger.py @@ -17,12 +17,8 @@ class TextChangedTrigger(EntityTriggerBase): _domain = DOMAIN _schema = ENTITY_STATE_TRIGGER_SCHEMA - def is_from_state(self, from_state: State, to_state: State) -> bool: - """Check if the state matches the origin state.""" - return from_state.state != to_state.state - - def is_to_state(self, state: State) -> bool: - """Check if the state matches the target state.""" + def is_valid_state(self, state: State) -> bool: + """Check if the new state is not invalid.""" return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 7ea7fd95fef..af1f882d38c 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -14,7 +14,12 @@ from tplink_omada_client.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import device_registry as dr from .config_flow import CONF_SITE, create_omada_client @@ -61,12 +66,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo entry.runtime_data = controller async def handle_reconnect_client(call: ServiceCall) -> None: - """Handle the service action call.""" + """Handle the service action to force reconnection of a network client.""" mac: str | None = call.data.get("mac") if not mac: - return + raise ServiceValidationError("MAC address is required") - await site_client.reconnect_client(mac) + try: + await site_client.reconnect_client(mac) + except OmadaClientException as ex: + raise HomeAssistantError("Failed to reconnect client") from ex hass.services.async_register(DOMAIN, "reconnect_client", handle_reconnect_client) diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml new file mode 100644 index 00000000000..7bb37b19cf4 --- /dev/null +++ b/homeassistant/components/tplink_omada/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: todo + comment: Actions are created in async_setup_entry, and need to be moved. + appropriate-polling: + status: done + comment: Service data APIs are polled every 5 minutes + brands: done + common-modules: + status: todo + comment: The coordinator for the update platform should be moved to common module. + config-flow-test-coverage: + status: todo + comment: "test_form_single_site is patching config flow internals, and should only patch external APIs. Must address feedback from #156697." + config-flow: done + dependency-transparency: + status: done + comment: API dependency published on PyPI, MIT licensed. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: done + comment: Omada Site unique ID checked during config flow. + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters or options flow yet. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: todo + comment: Stale devices are auto-deleted at startup, not yet during runtime. + + # Platinum + async-dependency: done + inject-websession: + status: done + comment: Uses async_create_clientsession in case where unsafe cookies are needed. + strict-typing: todo diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index c430193db66..6847d165c9a 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -17,6 +17,10 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "password": "Password for the Omada controller user.", + "username": "Username for the Omada controller user." + }, "description": "The provided credentials have stopped working. Please update them.", "title": "Update TP-Link Omada credentials" }, @@ -24,7 +28,10 @@ "data": { "site": "Site" }, - "title": "Choose which site(s) to manage" + "data_description": { + "site": "Select the site you want to manage in Home Assistant." + }, + "title": "Choose which site to manage" }, "user": { "data": { @@ -34,7 +41,10 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "URL of the management interface of your TP-Link Omada controller." + "host": "URL of the management interface of your TP-Link Omada controller.", + "password": "Password for the Omada controller user.", + "username": "Username for the Omada controller user.", + "verify_ssl": "Uncheck this box if you are using the default self-signed certificate on the controller." }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." } diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index e0416c77e65..2968bd2dd42 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -1,5 +1,6 @@ """Support for setting the Transmission BitTorrent client Turtle Mode.""" +import asyncio from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -12,6 +13,7 @@ from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordina from .entity import TransmissionEntity PARALLEL_UPDATES = 0 +AFTER_WRITE_SLEEP = 2 @dataclass(frozen=True, kw_only=True) @@ -70,6 +72,7 @@ class TransmissionSwitch(TransmissionEntity, SwitchEntity): await self.hass.async_add_executor_job( self.entity_description.on_func, self.coordinator ) + await asyncio.sleep(AFTER_WRITE_SLEEP) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: @@ -77,4 +80,5 @@ class TransmissionSwitch(TransmissionEntity, SwitchEntity): await self.hass.async_add_executor_job( self.entity_description.off_func, self.coordinator ) + await asyncio.sleep(AFTER_WRITE_SLEEP) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 9ce188a7e04..f31ea7d7473 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -20,7 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DPCodeBase64Wrapper, DPCodeEnumWrapper +from .models import DPCodeEnumWrapper, DPCodeRawWrapper +from .type_information import EnumTypeInformation ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = { DeviceCategory.MAL: ( @@ -32,7 +33,7 @@ ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = { } -class _AlarmChangedByWrapper(DPCodeBase64Wrapper): +class _AlarmChangedByWrapper(DPCodeRawWrapper): """Wrapper for changed_by. Decode base64 to utf-16be string, but only if alarm has been triggered. @@ -42,27 +43,20 @@ class _AlarmChangedByWrapper(DPCodeBase64Wrapper): """Read the device status.""" if ( device.status.get(DPCode.MASTER_STATE) != "alarm" - or (data := self.read_bytes(device)) is None + or (status := super().read_device_status(device)) is None ): return None - return data.decode("utf-16be") + return status.decode("utf-16be") -class _AlarmModeWrapper(DPCodeEnumWrapper): - """Wrapper for the alarm mode of a device. +class _AlarmStateWrapper(DPCodeEnumWrapper): + """Wrapper for the alarm state of a device. Handles alarm mode enum values and determines the alarm state, including logic for detecting when the alarm is triggered and distinguishing triggered state from battery warnings. """ - _ACTION_MAPPINGS = { - # Home Assistant action => Tuya device mode - "arm_home": "home", - "arm_away": "arm", - "disarm": "disarmed", - "trigger": "sos", - } _STATE_MAPPINGS = { # Tuya device mode => Home Assistant panel state "disarmed": AlarmControlPanelState.DISARMED, @@ -71,7 +65,9 @@ class _AlarmModeWrapper(DPCodeEnumWrapper): "sos": AlarmControlPanelState.TRIGGERED, } - def read_panel_state(self, device: CustomerDevice) -> AlarmControlPanelState | None: + def read_device_status( + self, device: CustomerDevice + ) -> AlarmControlPanelState | None: """Read the device status.""" # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'. # The 'mode' doesn't change, and stays as 'arm' or 'home'. @@ -84,22 +80,35 @@ class _AlarmModeWrapper(DPCodeEnumWrapper): ): return AlarmControlPanelState.TRIGGERED - if (status := self.read_device_status(device)) is None: + if (status := super().read_device_status(device)) is None: return None return self._STATE_MAPPINGS.get(status) - def supports_action(self, action: str) -> bool: - """Return if action is supported.""" - return ( - mapped_value := self._ACTION_MAPPINGS.get(action) - ) is not None and mapped_value in self.type_information.range + +class _AlarmActionWrapper(DPCodeEnumWrapper): + """Wrapper for setting the alarm mode of a device.""" + + _ACTION_MAPPINGS = { + # Home Assistant action => Tuya device mode + "arm_home": "home", + "arm_away": "arm", + "disarm": "disarmed", + "trigger": "sos", + } + + def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: + """Init _AlarmActionWrapper.""" + super().__init__(dpcode, type_information) + self.options = [ + ha_action + for ha_action, tuya_action in self._ACTION_MAPPINGS.items() + if tuya_action in type_information.range + ] def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert value to raw value.""" - if ( - mapped_value := self._ACTION_MAPPINGS.get(value) - ) is not None and mapped_value in self.type_information.range: - return mapped_value + if value in self.options: + return self._ACTION_MAPPINGS[value] raise ValueError(f"Unsupported value {value} for {self.dpcode}") @@ -123,14 +132,19 @@ async def async_setup_entry( device, manager, description, - mode_wrapper=mode_wrapper, + action_wrapper=_AlarmActionWrapper( + master_mode.dpcode, master_mode + ), changed_by_wrapper=_AlarmChangedByWrapper.find_dpcode( device, DPCode.ALARM_MSG ), + state_wrapper=_AlarmStateWrapper( + master_mode.dpcode, master_mode + ), ) for description in descriptions if ( - mode_wrapper := _AlarmModeWrapper.find_dpcode( + master_mode := EnumTypeInformation.find_dpcode( device, DPCode.MASTER_MODE, prefer_function=True ) ) @@ -156,48 +170,49 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): device_manager: Manager, description: AlarmControlPanelEntityDescription, *, - mode_wrapper: _AlarmModeWrapper, + action_wrapper: _AlarmActionWrapper, changed_by_wrapper: _AlarmChangedByWrapper | None, + state_wrapper: _AlarmStateWrapper, ) -> None: """Init Tuya Alarm.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" - self._mode_wrapper = mode_wrapper + self._action_wrapper = action_wrapper self._changed_by_wrapper = changed_by_wrapper + self._state_wrapper = state_wrapper # Determine supported modes - if mode_wrapper.supports_action("arm_home"): - self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME - if mode_wrapper.supports_action("arm_away"): - self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY - if mode_wrapper.supports_action("trigger"): - self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER + if action_wrapper.options: + if "arm_home" in action_wrapper.options: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + if "arm_away" in action_wrapper.options: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + if "trigger" in action_wrapper.options: + self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER @property def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" - return self._mode_wrapper.read_panel_state(self.device) + return self._read_wrapper(self._state_wrapper) @property def changed_by(self) -> str | None: """Last change triggered by.""" - if self._changed_by_wrapper is None: - return None return self._read_wrapper(self._changed_by_wrapper) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send Disarm command.""" - await self._async_send_wrapper_updates(self._mode_wrapper, "disarm") + await self._async_send_wrapper_updates(self._action_wrapper, "disarm") async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send Home command.""" - await self._async_send_wrapper_updates(self._mode_wrapper, "arm_home") + await self._async_send_wrapper_updates(self._action_wrapper, "arm_home") async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send Arm command.""" - await self._async_send_wrapper_updates(self._mode_wrapper, "arm_away") + await self._async_send_wrapper_updates(self._action_wrapper, "arm_away") async def async_alarm_trigger(self, code: str | None = None) -> None: """Send SOS command.""" - await self._async_send_wrapper_updates(self._mode_wrapper, "trigger") + await self._async_send_wrapper_updates(self._action_wrapper, "trigger") diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 72b44a71036..92b92f916c9 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -380,7 +380,7 @@ class _CustomDPCodeWrapper(DPCodeWrapper): def read_device_status(self, device: CustomerDevice) -> bool | None: """Read the device value for the dpcode.""" - if (raw_value := self._read_device_status_raw(device)) is None: + if (raw_value := device.status.get(self.dpcode)) is None: return None return raw_value in self._valid_values diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 30f2238628b..e9e361764d3 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -68,7 +68,7 @@ class _SwingModeWrapper(DeviceWrapper): on_off: DPCodeBooleanWrapper | None = None horizontal: DPCodeBooleanWrapper | None = None vertical: DPCodeBooleanWrapper | None = None - modes: list[str] + options: list[str] @classmethod def find_dpcode(cls, device: CustomerDevice) -> Self | None: @@ -83,18 +83,18 @@ class _SwingModeWrapper(DeviceWrapper): device, DPCode.SWITCH_VERTICAL, prefer_function=True ) if on_off or horizontal or vertical: - modes = [SWING_OFF] + options = [SWING_OFF] if on_off: - modes.append(SWING_ON) + options.append(SWING_ON) if horizontal: - modes.append(SWING_HORIZONTAL) + options.append(SWING_HORIZONTAL) if vertical: - modes.append(SWING_VERTICAL) + options.append(SWING_VERTICAL) return cls( on_off=on_off, horizontal=horizontal, vertical=vertical, - modes=modes, + options=options, ) return None @@ -361,11 +361,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # it to define min, max & step temperatures if self._set_temperature: self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_max_temp = self._set_temperature.type_information.max_scaled - self._attr_min_temp = self._set_temperature.type_information.min_scaled - self._attr_target_temperature_step = ( - self._set_temperature.type_information.step_scaled - ) + self._attr_max_temp = self._set_temperature.max_value + self._attr_min_temp = self._set_temperature.min_value + self._attr_target_temperature_step = self._set_temperature.value_step # Determine HVAC modes self._attr_hvac_modes: list[HVACMode] = [] @@ -373,7 +371,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if hvac_mode_wrapper: self._attr_hvac_modes = [HVACMode.OFF] unknown_hvac_modes: list[str] = [] - for tuya_mode in hvac_mode_wrapper.type_information.range: + for tuya_mode in hvac_mode_wrapper.options: if tuya_mode in TUYA_HVAC_TO_HA: ha_mode = TUYA_HVAC_TO_HA[tuya_mode] self._hvac_to_tuya[ha_mode] = tuya_mode @@ -394,22 +392,18 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # Determine dpcode to use for setting the humidity if target_humidity_wrapper: self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY - self._attr_min_humidity = round( - target_humidity_wrapper.type_information.min_scaled - ) - self._attr_max_humidity = round( - target_humidity_wrapper.type_information.max_scaled - ) + self._attr_min_humidity = round(target_humidity_wrapper.min_value) + self._attr_max_humidity = round(target_humidity_wrapper.max_value) # Determine fan modes if fan_mode_wrapper: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - self._attr_fan_modes = fan_mode_wrapper.type_information.range + self._attr_fan_modes = fan_mode_wrapper.options # Determine swing modes if swing_wrapper: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE - self._attr_swing_modes = swing_wrapper.modes + self._attr_swing_modes = swing_wrapper.options if switch_wrapper: self._attr_supported_features |= ( @@ -476,23 +470,23 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): return self._read_wrapper(self._target_humidity_wrapper) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return hvac mode.""" - # If the switch is off, hvac mode is off as well. - # Unless the switch doesn't exists of course... + # If the switch is off, hvac mode is off. + switch_status: bool | None if (switch_status := self._read_wrapper(self._switch_wrapper)) is False: return HVACMode.OFF - # If the mode is known and maps to an HVAC mode, return it. - if (mode := self._read_wrapper(self._hvac_mode_wrapper)) and ( - hvac_mode := TUYA_HVAC_TO_HA.get(mode) - ): - return hvac_mode + # If we don't have a mode wrapper, return switch only mode. + if self._hvac_mode_wrapper is None: + if switch_status is True: + return self.entity_description.switch_only_hvac_mode + return None - # If hvac_mode is unknown, return the switch only mode. - if switch_status: - return self.entity_description.switch_only_hvac_mode - return HVACMode.OFF + # If we do have a mode wrapper, check if the mode maps to an HVAC mode. + if (hvac_status := self._read_wrapper(self._hvac_mode_wrapper)) is None: + return None + return TUYA_HVAC_TO_HA.get(hvac_status) @property def preset_mode(self) -> str | None: diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 8753c0c757c..5c82d1f8f40 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -22,36 +22,42 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper +from .models import ( + DeviceWrapper, + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from .type_information import EnumTypeInformation, IntegerTypeInformation +from .util import RemapHelper class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper): """Wrapper for DPCode position values mapping to 0-100 range.""" + def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: + """Init DPCodeIntegerWrapper.""" + super().__init__(dpcode, type_information) + self._remap_helper = RemapHelper.from_type_information(type_information, 0, 100) + def _position_reversed(self, device: CustomerDevice) -> bool: """Check if the position and direction should be reversed.""" return False def read_device_status(self, device: CustomerDevice) -> float | None: - if (value := self._read_device_status_raw(device)) is None: + if (value := device.status.get(self.dpcode)) is None: return None return round( - self.type_information.remap_value_to( - value, - 0, - 100, - self._position_reversed(device), + self._remap_helper.remap_value_to( + value, reverse=self._position_reversed(device) ) ) def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: return round( - self.type_information.remap_value_from( - value, - 0, - 100, - self._position_reversed(device), + self._remap_helper.remap_value_from( + value, reverse=self._position_reversed(device) ) ) @@ -72,58 +78,37 @@ class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper): return device.status.get(DPCode.CONTROL_BACK_MODE) != "back" -class _InstructionWrapper: - """Default wrapper for sending open/close/stop instructions.""" - - def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None: - return None - - def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None: - return None - - def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None: - return None - - -class _InstructionBooleanWrapper(DPCodeBooleanWrapper, _InstructionWrapper): +class _InstructionBooleanWrapper(DPCodeBooleanWrapper): """Wrapper for boolean-based open/close instructions.""" - def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None: - return {"code": self.dpcode, "value": True} + options = ["open", "close"] + _ACTION_MAPPINGS = {"open": True, "close": False} - def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None: - return {"code": self.dpcode, "value": False} + def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> bool: + return self._ACTION_MAPPINGS[value] -class _InstructionEnumWrapper(DPCodeEnumWrapper, _InstructionWrapper): +class _InstructionEnumWrapper(DPCodeEnumWrapper): """Wrapper for enum-based open/close/stop instructions.""" - open_instruction = "open" - close_instruction = "close" - stop_instruction = "stop" + _ACTION_MAPPINGS = {"open": "open", "close": "close", "stop": "stop"} - def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None: - if self.open_instruction in self.type_information.range: - return {"code": self.dpcode, "value": self.open_instruction} - return None + def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: + super().__init__(dpcode, type_information) + self.options = [ + ha_action + for ha_action, tuya_action in self._ACTION_MAPPINGS.items() + if tuya_action in type_information.range + ] - def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None: - if self.close_instruction in self.type_information.range: - return {"code": self.dpcode, "value": self.close_instruction} - return None - - def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None: - if self.stop_instruction in self.type_information.range: - return {"code": self.dpcode, "value": self.stop_instruction} - return None + def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> str: + return self._ACTION_MAPPINGS[value] class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper): """Wrapper for enum-based instructions with special values (FZ/ZZ/STOP).""" - open_instruction = "FZ" - close_instruction = "ZZ" - stop_instruction = "STOP" + _ACTION_MAPPINGS = {"open": "FZ", "close": "ZZ", "stop": "STOP"} class _IsClosedWrapper: @@ -277,7 +262,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = { def _get_instruction_wrapper( device: CustomerDevice, description: TuyaCoverEntityDescription -) -> _InstructionWrapper | None: +) -> DeviceWrapper | None: """Get the instruction wrapper for the cover entity.""" if enum_wrapper := description.instruction_wrapper.find_dpcode( device, description.key, prefer_function=True @@ -357,7 +342,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): *, current_position: _DPCodePercentageMappingWrapper | None, current_state_wrapper: _IsClosedWrapper | None, - instruction_wrapper: _InstructionWrapper | None, + instruction_wrapper: DeviceWrapper | None, set_position: _DPCodePercentageMappingWrapper | None, tilt_position: _DPCodePercentageMappingWrapper | None, ) -> None: @@ -373,12 +358,12 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): self._set_position = set_position self._tilt_position = tilt_position - if instruction_wrapper: - if instruction_wrapper.get_open_command(device) is not None: + if instruction_wrapper and instruction_wrapper.options is not None: + if "open" in instruction_wrapper.options: self._attr_supported_features |= CoverEntityFeature.OPEN - if instruction_wrapper.get_close_command(device) is not None: + if "close" in instruction_wrapper.options: self._attr_supported_features |= CoverEntityFeature.CLOSE - if instruction_wrapper.get_stop_command(device) is not None: + if "stop" in instruction_wrapper.options: self._attr_supported_features |= CoverEntityFeature.STOP if set_position: @@ -413,10 +398,12 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - if self._instruction_wrapper and ( - command := self._instruction_wrapper.get_open_command(self.device) + if ( + self._instruction_wrapper + and (options := self._instruction_wrapper.options) + and "open" in options ): - await self._async_send_commands([command]) + await self._async_send_wrapper_updates(self._instruction_wrapper, "open") return if self._set_position is not None: @@ -426,10 +413,12 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - if self._instruction_wrapper and ( - command := self._instruction_wrapper.get_close_command(self.device) + if ( + self._instruction_wrapper + and (options := self._instruction_wrapper.options) + and "close" in options ): - await self._async_send_commands([command]) + await self._async_send_wrapper_updates(self._instruction_wrapper, "close") return if self._set_position is not None: @@ -445,10 +434,12 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - if self._instruction_wrapper and ( - command := self._instruction_wrapper.get_stop_command(self.device) + if ( + self._instruction_wrapper + and (options := self._instruction_wrapper.options) + and "stop" in options ): - await self._async_send_commands([command]) + await self._async_send_wrapper_updates(self._instruction_wrapper, "stop") async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index ec7fa3cb140..78a3d7631c3 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -98,6 +98,7 @@ def _async_device_as_dict( "home_assistant": {}, "set_up": device.set_up, "support_local": device.support_local, + "local_strategy": device.local_strategy, "warnings": DEVICE_WARNINGS.get(device.id), } diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 80ba6245e0c..701ee1c7840 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -21,8 +21,8 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity from .models import ( - DPCodeBase64Wrapper, DPCodeEnumWrapper, + DPCodeRawWrapper, DPCodeStringWrapper, DPCodeTypeInformationWrapper, ) @@ -31,10 +31,12 @@ from .models import ( class _DPCodeEventWrapper(DPCodeTypeInformationWrapper): """Base class for Tuya event wrappers.""" - @property - def event_types(self) -> list[str]: - """Return the event types for the DP code.""" - return ["triggered"] + options: list[str] + + def __init__(self, dpcode: str, type_information: Any) -> None: + """Init _DPCodeEventWrapper.""" + super().__init__(dpcode, type_information) + self.options = ["triggered"] def get_event_type( self, device: CustomerDevice, updated_status_properties: list[str] | None @@ -55,11 +57,6 @@ class _DPCodeEventWrapper(DPCodeTypeInformationWrapper): class _EventEnumWrapper(DPCodeEnumWrapper, _DPCodeEventWrapper): """Wrapper for event enum DP codes.""" - @property - def event_types(self) -> list[str]: - """Return the event types for the enum.""" - return self.type_information.range - def get_event_type( self, device: CustomerDevice, updated_status_properties: list[str] | None ) -> str | None: @@ -77,12 +74,12 @@ class _AlarmMessageWrapper(DPCodeStringWrapper, _DPCodeEventWrapper): def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None: """Return the event attributes for the alarm message.""" - if (raw_value := self._read_device_status_raw(device)) is None: + if (raw_value := device.status.get(self.dpcode)) is None: return None return {"message": b64decode(raw_value).decode("utf-8")} -class _DoorbellPicWrapper(DPCodeBase64Wrapper, _DPCodeEventWrapper): +class _DoorbellPicWrapper(DPCodeRawWrapper, _DPCodeEventWrapper): """Wrapper for a RAW message on DPCode.DOORBELL_PIC. It is expected that the RAW data is base64/utf8 encoded URL of the picture. @@ -90,9 +87,9 @@ class _DoorbellPicWrapper(DPCodeBase64Wrapper, _DPCodeEventWrapper): def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None: """Return the event attributes for the doorbell picture.""" - if (raw_value := self._read_device_status_raw(device)) is None: + if (status := super().read_device_status(device)) is None: return None - return {"message": b64decode(raw_value).decode("utf-8")} + return {"message": status.decode("utf-8")} @dataclass(frozen=True) @@ -232,7 +229,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity): self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" self._dpcode_wrapper = dpcode_wrapper - self._attr_event_types = dpcode_wrapper.event_types + self._attr_event_types = dpcode_wrapper.options async def _handle_state_update( self, diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 585e69e97ca..f82f7bb795b 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -24,7 +24,8 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper -from .util import get_dpcode +from .type_information import IntegerTypeInformation +from .util import RemapHelper, get_dpcode _DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) _MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE) @@ -76,37 +77,34 @@ def _has_a_valid_dpcode(device: CustomerDevice) -> bool: class _FanSpeedEnumWrapper(DPCodeEnumWrapper): """Wrapper for fan speed DP code (from an enum).""" - def get_speed_count(self) -> int: - """Get the number of speeds supported by the fan.""" - return len(self.type_information.range) - def read_device_status(self, device: CustomerDevice) -> int | None: """Get the current speed as a percentage.""" if (value := super().read_device_status(device)) is None: return None - return ordered_list_item_to_percentage(self.type_information.range, value) + return ordered_list_item_to_percentage(self.options, value) def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert a Home Assistant value back to a raw device value.""" - return percentage_to_ordered_list_item(self.type_information.range, value) + return percentage_to_ordered_list_item(self.options, value) class _FanSpeedIntegerWrapper(DPCodeIntegerWrapper): """Wrapper for fan speed DP code (from an integer).""" - def get_speed_count(self) -> int: - """Get the number of speeds supported by the fan.""" - return 100 + def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: + """Init DPCodeIntegerWrapper.""" + super().__init__(dpcode, type_information) + self._remap_helper = RemapHelper.from_type_information(type_information, 1, 100) def read_device_status(self, device: CustomerDevice) -> int | None: """Get the current speed as a percentage.""" if (value := super().read_device_status(device)) is None: return None - return round(self.type_information.remap_value_to(value, 1, 100)) + return round(self._remap_helper.remap_value_to(value)) def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert a Home Assistant value back to a raw device value.""" - return round(self.type_information.remap_value_from(value, 1, 100)) + return round(self._remap_helper.remap_value_from(value)) def _get_speed_wrapper( @@ -191,11 +189,12 @@ class TuyaFanEntity(TuyaEntity, FanEntity): if mode_wrapper: self._attr_supported_features |= FanEntityFeature.PRESET_MODE - self._attr_preset_modes = mode_wrapper.type_information.range + self._attr_preset_modes = mode_wrapper.options if speed_wrapper: self._attr_supported_features |= FanEntityFeature.SET_SPEED - self._attr_speed_count = speed_wrapper.get_speed_count() + if speed_wrapper.options is not None: + self._attr_speed_count = len(speed_wrapper.options) if oscillate_wrapper: self._attr_supported_features |= FanEntityFeature.OSCILLATE diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index af1613fe330..377bdd500e3 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -153,17 +153,13 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): # Determine humidity parameters if target_humidity_wrapper: - self._attr_min_humidity = round( - target_humidity_wrapper.type_information.min_scaled - ) - self._attr_max_humidity = round( - target_humidity_wrapper.type_information.max_scaled - ) + self._attr_min_humidity = round(target_humidity_wrapper.min_value) + self._attr_max_humidity = round(target_humidity_wrapper.max_value) # Determine mode support and provided modes if mode_wrapper: self._attr_supported_features |= HumidifierEntityFeature.MODES - self._attr_available_modes = mode_wrapper.type_information.range + self._attr_available_modes = mode_wrapper.options @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index bcbc467c2e9..aef9908c825 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -37,7 +37,7 @@ from .models import ( DPCodeJsonWrapper, ) from .type_information import IntegerTypeInformation -from .util import remap_value +from .util import RemapHelper class _BrightnessWrapper(DPCodeIntegerWrapper): @@ -50,36 +50,45 @@ class _BrightnessWrapper(DPCodeIntegerWrapper): brightness_min: DPCodeIntegerWrapper | None = None brightness_max: DPCodeIntegerWrapper | None = None + brightness_min_remap: RemapHelper | None = None + brightness_max_remap: RemapHelper | None = None + + def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: + """Init DPCodeIntegerWrapper.""" + super().__init__(dpcode, type_information) + self._remap_helper = RemapHelper.from_type_information(type_information, 0, 255) def read_device_status(self, device: CustomerDevice) -> Any | None: """Return the brightness of this light between 0..255.""" - if (brightness := self._read_device_status_raw(device)) is None: + if (brightness := device.status.get(self.dpcode)) is None: return None # Remap value to our scale - brightness = self.type_information.remap_value_to(brightness) + brightness = self._remap_helper.remap_value_to(brightness) # If there is a min/max value, the brightness is actually limited. # Meaning it is actually not on a 0-255 scale. if ( self.brightness_max is not None and self.brightness_min is not None + and self.brightness_max_remap is not None + and self.brightness_min_remap is not None and (brightness_max := device.status.get(self.brightness_max.dpcode)) is not None and (brightness_min := device.status.get(self.brightness_min.dpcode)) is not None ): # Remap values onto our scale - brightness_max = self.brightness_max.type_information.remap_value_to( - brightness_max - ) - brightness_min = self.brightness_min.type_information.remap_value_to( - brightness_min - ) + brightness_max = self.brightness_max_remap.remap_value_to(brightness_max) + brightness_min = self.brightness_min_remap.remap_value_to(brightness_min) # Remap the brightness value from their min-max to our 0-255 scale - brightness = remap_value( - brightness, from_min=brightness_min, from_max=brightness_max + brightness = RemapHelper.remap_value( + brightness, + from_min=brightness_min, + from_max=brightness_max, + to_min=0, + to_max=255, ) return round(brightness) @@ -91,72 +100,69 @@ class _BrightnessWrapper(DPCodeIntegerWrapper): if ( self.brightness_max is not None and self.brightness_min is not None + and self.brightness_max_remap is not None + and self.brightness_min_remap is not None and (brightness_max := device.status.get(self.brightness_max.dpcode)) is not None and (brightness_min := device.status.get(self.brightness_min.dpcode)) is not None ): # Remap values onto our scale - brightness_max = self.brightness_max.type_information.remap_value_to( - brightness_max - ) - brightness_min = self.brightness_min.type_information.remap_value_to( - brightness_min - ) + brightness_max = self.brightness_max_remap.remap_value_to(brightness_max) + brightness_min = self.brightness_min_remap.remap_value_to(brightness_min) # Remap the brightness value from our 0-255 scale to their min-max - value = remap_value(value, to_min=brightness_min, to_max=brightness_max) - return round(self.type_information.remap_value_from(value)) + value = RemapHelper.remap_value( + value, + from_min=0, + from_max=255, + to_min=brightness_min, + to_max=brightness_max, + ) + return round(self._remap_helper.remap_value_from(value)) class _ColorTempWrapper(DPCodeIntegerWrapper): """Wrapper for color temperature DP code.""" + def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: + """Init DPCodeIntegerWrapper.""" + super().__init__(dpcode, type_information) + self._remap_helper = RemapHelper.from_type_information( + type_information, MIN_MIREDS, MAX_MIREDS + ) + def read_device_status(self, device: CustomerDevice) -> Any | None: """Return the color temperature value in Kelvin.""" - if (temperature := self._read_device_status_raw(device)) is None: + if (temperature := device.status.get(self.dpcode)) is None: return None return color_util.color_temperature_mired_to_kelvin( - self.type_information.remap_value_to( - temperature, - MIN_MIREDS, - MAX_MIREDS, - reverse=True, - ) + self._remap_helper.remap_value_to(temperature, reverse=True) ) def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert a Home Assistant value (Kelvin) back to a raw device value.""" return round( - self.type_information.remap_value_from( - color_util.color_temperature_kelvin_to_mired(value), - MIN_MIREDS, - MAX_MIREDS, - reverse=True, + self._remap_helper.remap_value_from( + color_util.color_temperature_kelvin_to_mired(value), reverse=True ) ) -DEFAULT_H_TYPE = IntegerTypeInformation( - dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1 -) -DEFAULT_S_TYPE = IntegerTypeInformation( - dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1 -) -DEFAULT_V_TYPE = IntegerTypeInformation( - dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1 -) +DEFAULT_H_TYPE = RemapHelper(source_min=1, source_max=360, target_min=0, target_max=360) +DEFAULT_S_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=100) +DEFAULT_V_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=255) -DEFAULT_H_TYPE_V2 = IntegerTypeInformation( - dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1 +DEFAULT_H_TYPE_V2 = RemapHelper( + source_min=1, source_max=360, target_min=0, target_max=360 ) -DEFAULT_S_TYPE_V2 = IntegerTypeInformation( - dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1 +DEFAULT_S_TYPE_V2 = RemapHelper( + source_min=1, source_max=1000, target_min=0, target_max=100 ) -DEFAULT_V_TYPE_V2 = IntegerTypeInformation( - dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1 +DEFAULT_V_TYPE_V2 = RemapHelper( + source_min=1, source_max=1000, target_min=0, target_max=255 ) @@ -167,36 +173,27 @@ class _ColorDataWrapper(DPCodeJsonWrapper): s_type = DEFAULT_S_TYPE v_type = DEFAULT_V_TYPE - def read_device_status(self, device: CustomerDevice) -> dict[str, Any] | None: - """Read the color data for the dpcode.""" - if (status_data := self._read_device_status_raw(device)) is None or not ( - status := json_loads_object(status_data) - ): - return None - return status - - def read_hs_color(self, device: CustomerDevice) -> tuple[float, float] | None: - """Get the HS value from this color data.""" - if (status := self.read_device_status(device)) is None: + def read_device_status( + self, device: CustomerDevice + ) -> tuple[float, float, float] | None: + """Return a tuple (H, S, V) from this color data.""" + if (status := super().read_device_status(device)) is None: return None return ( - self.h_type.remap_value_to(cast(int, status["h"]), 0, 360), - self.s_type.remap_value_to(cast(int, status["s"]), 0, 100), + self.h_type.remap_value_to(status["h"]), + self.s_type.remap_value_to(status["s"]), + self.v_type.remap_value_to(status["v"]), ) - def read_brightness(self, device: CustomerDevice) -> int | None: - """Get the brightness value from this color data.""" - if (status := self.read_device_status(device)) is None: - return None - return round(self.v_type.remap_value_to(cast(int, status["v"]), 0, 255)) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: + def _convert_value_to_raw_value( + self, device: CustomerDevice, value: tuple[tuple[float, float], float] + ) -> Any: """Convert a Home Assistant color/brightness pair back to a raw device value.""" color, brightness = value return json.dumps( { - "h": round(self.h_type.remap_value_from(color[0], 0, 360)), - "s": round(self.s_type.remap_value_from(color[1], 0, 100)), + "h": round(self.h_type.remap_value_from(color[0])), + "s": round(self.s_type.remap_value_from(color[1])), "v": round(self.v_type.remap_value_from(brightness)), } ) @@ -553,12 +550,20 @@ def _get_brightness_wrapper( ) ) is None: return None - brightness_wrapper.brightness_max = DPCodeIntegerWrapper.find_dpcode( + if brightness_max := DPCodeIntegerWrapper.find_dpcode( device, description.brightness_max, prefer_function=True - ) - brightness_wrapper.brightness_min = DPCodeIntegerWrapper.find_dpcode( + ): + brightness_wrapper.brightness_max = brightness_max + brightness_wrapper.brightness_max_remap = RemapHelper.from_type_information( + brightness_max.type_information, 0, 255 + ) + if brightness_min := DPCodeIntegerWrapper.find_dpcode( device, description.brightness_min, prefer_function=True - ) + ): + brightness_wrapper.brightness_min = brightness_min + brightness_wrapper.brightness_min_remap = RemapHelper.from_type_information( + brightness_min.type_information, 0, 255 + ) return brightness_wrapper @@ -576,24 +581,21 @@ def _get_color_data_wrapper( # Fetch color data type information if function_data := json_loads_object( - cast(str, color_data_wrapper.type_information.type_data) + color_data_wrapper.type_information.type_data ): - color_data_wrapper.h_type = IntegerTypeInformation( - dpcode=color_data_wrapper.dpcode, - **cast(dict, function_data["h"]), + color_data_wrapper.h_type = RemapHelper.from_function_data( + cast(dict, function_data["h"]), 0, 360 ) - color_data_wrapper.s_type = IntegerTypeInformation( - dpcode=color_data_wrapper.dpcode, - **cast(dict, function_data["s"]), + color_data_wrapper.s_type = RemapHelper.from_function_data( + cast(dict, function_data["s"]), 0, 100 ) - color_data_wrapper.v_type = IntegerTypeInformation( - dpcode=color_data_wrapper.dpcode, - **cast(dict, function_data["v"]), + color_data_wrapper.v_type = RemapHelper.from_function_data( + cast(dict, function_data["v"]), 0, 255 ) elif ( description.fallback_color_data_mode == FallbackColorDataMode.V2 or color_data_wrapper.dpcode == DPCode.COLOUR_DATA_V2 - or (brightness_wrapper and brightness_wrapper.type_information.max > 255) + or (brightness_wrapper and brightness_wrapper.max_value > 255) ): color_data_wrapper.h_type = DEFAULT_H_TYPE_V2 color_data_wrapper.s_type = DEFAULT_S_TYPE_V2 @@ -703,7 +705,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): elif ( color_supported(color_modes) and color_mode_wrapper is not None - and WorkMode.WHITE in color_mode_wrapper.type_information.range + and WorkMode.WHITE in color_mode_wrapper.options ): color_modes.add(ColorMode.WHITE) self._white_color_mode = ColorMode.WHITE @@ -762,7 +764,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): commands.extend( self._color_data_wrapper.get_update_commands( - self.device, (color, brightness) + self.device, (color[0], color[1], brightness) ), ) @@ -789,7 +791,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Return the brightness of this light between 0..255.""" # If the light is currently in color mode, extract the brightness from the color data if self.color_mode == ColorMode.HS and self._color_data_wrapper: - return self._color_data_wrapper.read_brightness(self.device) + hsv_data = self._read_wrapper(self._color_data_wrapper) + return None if hsv_data is None else round(hsv_data[2]) return self._read_wrapper(self._brightness_wrapper) @@ -803,7 +806,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Return the hs_color of the light.""" if self._color_data_wrapper is None: return None - return self._color_data_wrapper.read_hs_color(self.device) + hsv_data = self._read_wrapper(self._color_data_wrapper) + return None if hsv_data is None else (hsv_data[0], hsv_data[1]) @property def color_mode(self) -> ColorMode: diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 79fad30f52c..e1a23aa4bc2 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -2,13 +2,10 @@ from __future__ import annotations -import base64 from typing import Any, Self from tuya_sharing import CustomerDevice -from homeassistant.util.json import json_loads - from .type_information import ( BitmapTypeInformation, BooleanTypeInformation, @@ -24,6 +21,8 @@ from .type_information import ( class DeviceWrapper: """Base device wrapper.""" + options: list[str] | None = None + def read_device_status(self, device: CustomerDevice) -> Any | None: """Read device status and convert to a Home Assistant value.""" raise NotImplementedError @@ -49,13 +48,6 @@ class DPCodeWrapper(DeviceWrapper): """Init DPCodeWrapper.""" self.dpcode = dpcode - def _read_device_status_raw(self, device: CustomerDevice) -> Any | None: - """Read the raw device status for the DPCode. - - Private helper method for `read_device_status`. - """ - return device.status.get(self.dpcode) - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert a Home Assistant value back to a raw device value. @@ -93,7 +85,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper): def read_device_status(self, device: CustomerDevice) -> Any | None: """Read the device value for the dpcode.""" return self.type_information.process_raw_value( - self._read_device_status_raw(device), device + device.status.get(self.dpcode), device ) @classmethod @@ -114,20 +106,6 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper): return None -class DPCodeBase64Wrapper(DPCodeTypeInformationWrapper[RawTypeInformation]): - """Wrapper to extract information from a RAW/binary value.""" - - _DPTYPE = RawTypeInformation - - def read_bytes(self, device: CustomerDevice) -> bytes | None: - """Read the device value for the dpcode.""" - if (raw_value := self._read_device_status_raw(device)) is None or ( - len(decoded := base64.b64decode(raw_value)) == 0 - ): - return None - return decoded - - class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[BooleanTypeInformation]): """Simple wrapper for boolean values. @@ -152,17 +130,17 @@ class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[JsonTypeInformation]): _DPTYPE = JsonTypeInformation - def read_json(self, device: CustomerDevice) -> Any | None: - """Read the device value for the dpcode.""" - if (raw_value := self._read_device_status_raw(device)) is None: - return None - return json_loads(raw_value) - class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): """Simple wrapper for EnumTypeInformation values.""" _DPTYPE = EnumTypeInformation + options: list[str] + + def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: + """Init DPCodeEnumWrapper.""" + super().__init__(dpcode, type_information) + self.options = type_information.range def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert a Home Assistant value back to a raw device value.""" @@ -184,6 +162,9 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation]) """Init DPCodeIntegerWrapper.""" super().__init__(dpcode, type_information) self.native_unit = type_information.unit + self.min_value = self.type_information.scale_value(type_information.min) + self.max_value = self.type_information.scale_value(type_information.max) + self.value_step = self.type_information.scale_value(type_information.step) def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: """Convert a Home Assistant value back to a raw device value.""" @@ -198,6 +179,12 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation]) ) +class DPCodeRawWrapper(DPCodeTypeInformationWrapper[RawTypeInformation]): + """Wrapper to extract information from a RAW/binary value.""" + + _DPTYPE = RawTypeInformation + + class DPCodeStringWrapper(DPCodeTypeInformationWrapper[StringTypeInformation]): """Wrapper to extract information from a STRING value.""" @@ -214,7 +201,7 @@ class DPCodeBitmapBitWrapper(DPCodeWrapper): def read_device_status(self, device: CustomerDevice) -> bool | None: """Read the device value for the dpcode.""" - if (raw_value := self._read_device_status_raw(device)) is None: + if (raw_value := device.status.get(self.dpcode)) is None: return None return (raw_value & (1 << self._mask)) != 0 diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index c5bdbd0f466..d20958c87fe 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -496,9 +496,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_unique_id = f"{super().unique_id}{description.key}" self._dpcode_wrapper = dpcode_wrapper - self._attr_native_max_value = dpcode_wrapper.type_information.max_scaled - self._attr_native_min_value = dpcode_wrapper.type_information.min_scaled - self._attr_native_step = dpcode_wrapper.type_information.step_scaled + self._attr_native_max_value = dpcode_wrapper.max_value + self._attr_native_min_value = dpcode_wrapper.min_value + self._attr_native_step = dpcode_wrapper.value_step if description.native_unit_of_measurement is None: self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 18ea9b13119..456512a6c17 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -400,7 +400,7 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity): self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" self._dpcode_wrapper = dpcode_wrapper - self._attr_options = dpcode_wrapper.type_information.range + self._attr_options = dpcode_wrapper.options @property def current_option(self) -> str | None: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 278295ff19a..03e49f0b0f7 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -39,10 +39,10 @@ from .const import ( ) from .entity import TuyaEntity from .models import ( - DPCodeBase64Wrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper, DPCodeJsonWrapper, + DPCodeRawWrapper, DPCodeTypeInformationWrapper, DPCodeWrapper, ) @@ -76,9 +76,7 @@ class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): def read_device_status(self, device: CustomerDevice) -> float | None: """Read the device value for the dpcode.""" - if ( - raw_value := self._read_device_status_raw(device) - ) in self.type_information.range: + if (raw_value := device.status.get(self.dpcode)) in self.type_information.range: return self._WIND_DIRECTIONS.get(raw_value) return None @@ -90,9 +88,9 @@ class _JsonElectricityCurrentWrapper(DPCodeJsonWrapper): def read_device_status(self, device: CustomerDevice) -> float | None: """Read the device value for the dpcode.""" - if (raw_value := super().read_json(device)) is None: + if (status := super().read_device_status(device)) is None: return None - return raw_value.get("electricCurrent") + return status.get("electricCurrent") class _JsonElectricityPowerWrapper(DPCodeJsonWrapper): @@ -102,9 +100,9 @@ class _JsonElectricityPowerWrapper(DPCodeJsonWrapper): def read_device_status(self, device: CustomerDevice) -> float | None: """Read the device value for the dpcode.""" - if (raw_value := super().read_json(device)) is None: + if (status := super().read_device_status(device)) is None: return None - return raw_value.get("power") + return status.get("power") class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper): @@ -114,12 +112,12 @@ class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper): def read_device_status(self, device: CustomerDevice) -> float | None: """Read the device value for the dpcode.""" - if (raw_value := super().read_json(device)) is None: + if (status := super().read_device_status(device)) is None: return None - return raw_value.get("voltage") + return status.get("voltage") -class _RawElectricityDataWrapper(DPCodeBase64Wrapper): +class _RawElectricityDataWrapper(DPCodeRawWrapper): """Custom DPCode Wrapper for extracting ElectricityData from base64.""" def _convert(self, value: ElectricityData) -> float: @@ -128,7 +126,7 @@ class _RawElectricityDataWrapper(DPCodeBase64Wrapper): def read_device_status(self, device: CustomerDevice) -> float | None: """Read the device value for the dpcode.""" - if (raw_value := super().read_bytes(device)) is None or ( + if (raw_value := super().read_device_status(device)) is None or ( value := ElectricityData.from_bytes(raw_value) ) is None: return None diff --git a/homeassistant/components/tuya/type_information.py b/homeassistant/components/tuya/type_information.py index 00ac7b37701..ff06b22c28b 100644 --- a/homeassistant/components/tuya/type_information.py +++ b/homeassistant/components/tuya/type_information.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 from dataclasses import dataclass from typing import Any, ClassVar, Self, cast @@ -10,7 +11,7 @@ from tuya_sharing import CustomerDevice from homeassistant.util.json import json_loads_object from .const import LOGGER, DPType -from .util import parse_dptype, remap_value +from .util import parse_dptype # Dictionary to track logged warnings to avoid spamming logs # Keyed by device ID @@ -40,7 +41,7 @@ class TypeInformation[T]: _DPTYPE: ClassVar[DPType] dpcode: str - type_data: str | None = None + type_data: str def process_raw_value( self, raw_value: Any | None, device: CustomerDevice @@ -105,12 +106,12 @@ class BitmapTypeInformation(TypeInformation[int]): @classmethod def _from_json(cls, dpcode: str, type_data: str) -> Self | None: """Load JSON string and return a BitmapTypeInformation object.""" - if not (parsed := json_loads_object(type_data)): + if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): return None return cls( dpcode=dpcode, type_data=type_data, - **cast(dict[str, list[str]], parsed), + label=parsed["label"], ) @@ -199,21 +200,6 @@ class IntegerTypeInformation(TypeInformation[float]): step: int unit: str | None = None - @property - def max_scaled(self) -> float: - """Return the max scaled.""" - return self.scale_value(self.max) - - @property - def min_scaled(self) -> float: - """Return the min scaled.""" - return self.scale_value(self.min) - - @property - def step_scaled(self) -> float: - """Return the step scaled.""" - return self.step / (10**self.scale) - def scale_value(self, value: int) -> float: """Scale a value.""" return value / (10**self.scale) @@ -222,26 +208,6 @@ class IntegerTypeInformation(TypeInformation[float]): """Return raw value for scaled.""" return round(value * (10**self.scale)) - def remap_value_to( - self, - value: float, - to_min: float = 0, - to_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from this range to a new range.""" - return remap_value(value, self.min, self.max, to_min, to_max, reverse) - - def remap_value_from( - self, - value: float, - from_min: float = 0, - from_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from its current range to this range.""" - return remap_value(value, from_min, from_max, self.min, self.max, reverse) - def process_raw_value( self, raw_value: Any | None, device: CustomerDevice ) -> float | None: @@ -285,18 +251,34 @@ class IntegerTypeInformation(TypeInformation[float]): @dataclass(kw_only=True) -class JsonTypeInformation(TypeInformation[Any]): +class JsonTypeInformation(TypeInformation[dict[str, Any]]): """Json type information.""" _DPTYPE = DPType.JSON + def process_raw_value( + self, raw_value: Any | None, device: CustomerDevice + ) -> dict[str, Any] | None: + """Read and process raw value against this type information.""" + if raw_value is None: + return None + return json_loads_object(raw_value) + @dataclass(kw_only=True) -class RawTypeInformation(TypeInformation[Any]): +class RawTypeInformation(TypeInformation[bytes]): """Raw type information.""" _DPTYPE = DPType.RAW + def process_raw_value( + self, raw_value: Any | None, device: CustomerDevice + ) -> bytes | None: + """Read and process raw value against this type information.""" + if raw_value is None: + return None + return base64.b64decode(raw_value) + @dataclass(kw_only=True) class StringTypeInformation(TypeInformation[str]): diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index aa554b6e1cd..0b1b549d62a 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,12 +2,18 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + from tuya_sharing import CustomerDevice from homeassistant.exceptions import ServiceValidationError from .const import DOMAIN, DPCode, DPType +if TYPE_CHECKING: + from .type_information import IntegerTypeInformation + _DPTYPE_MAPPING: dict[str, DPType] = { "bitmap": DPType.BITMAP, "bool": DPType.BOOLEAN, @@ -50,18 +56,78 @@ def parse_dptype(dptype: str) -> DPType | None: return _DPTYPE_MAPPING.get(dptype) -def remap_value( - value: float, - from_min: float = 0, - from_max: float = 255, - to_min: float = 0, - to_max: float = 255, - reverse: bool = False, -) -> float: - """Remap a value from its current range, to a new range.""" - if reverse: - value = from_max - value + from_min - return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min +@dataclass(kw_only=True) +class RemapHelper: + """Helper class for remapping values.""" + + source_min: int + source_max: int + target_min: int + target_max: int + + @classmethod + def from_type_information( + cls, + type_information: IntegerTypeInformation, + target_min: int, + target_max: int, + ) -> RemapHelper: + """Create RemapHelper from IntegerTypeInformation.""" + return cls( + source_min=type_information.min, + source_max=type_information.max, + target_min=target_min, + target_max=target_max, + ) + + @classmethod + def from_function_data( + cls, function_data: dict[str, Any], target_min: int, target_max: int + ) -> RemapHelper: + """Create RemapHelper from function_data.""" + return cls( + source_min=function_data["min"], + source_max=function_data["max"], + target_min=target_min, + target_max=target_max, + ) + + def remap_value_to(self, value: float, *, reverse: bool = False) -> float: + """Remap a value from this range to a new range.""" + return self.remap_value( + value, + self.source_min, + self.source_max, + self.target_min, + self.target_max, + reverse=reverse, + ) + + def remap_value_from(self, value: float, *, reverse: bool = False) -> float: + """Remap a value from its current range to this range.""" + return self.remap_value( + value, + self.target_min, + self.target_max, + self.source_min, + self.source_max, + reverse=reverse, + ) + + @staticmethod + def remap_value( + value: float, + from_min: float, + from_max: float, + to_min: float, + to_max: float, + *, + reverse: bool = False, + ) -> float: + """Remap a value from its current range, to a new range.""" + if reverse: + value = from_max - value + from_min + return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min class ActionDPCodeNotFoundError(ServiceValidationError): diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 8f11455a965..9a8f2469e26 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -128,15 +128,14 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._switch_wrapper = switch_wrapper self._attr_fan_speed_list = [] - self._attr_supported_features = ( - VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE - ) + self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND + if status_wrapper or pause_wrapper: + self._attr_supported_features |= VacuumEntityFeature.STATE if pause_wrapper: self._attr_supported_features |= VacuumEntityFeature.PAUSE if charge_wrapper or ( - mode_wrapper - and TUYA_MODE_RETURN_HOME in mode_wrapper.type_information.range + mode_wrapper and TUYA_MODE_RETURN_HOME in mode_wrapper.options ): self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME @@ -149,7 +148,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): ) if fan_speed_wrapper: - self._attr_fan_speed_list = fan_speed_wrapper.type_information.range + self._attr_fan_speed_list = fan_speed_wrapper.options self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED @property diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index d465429db36..c312ceda547 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -40,7 +40,7 @@ from .const import ( PLATFORMS, ) from .data import ProtectData, UFPConfigEntry -from .discovery import async_start_discovery +from .discovery import DATA_UNIFIPROTECT, UniFiProtectRuntimeData, async_start_discovery from .migrate import async_migrate_data from .services import async_setup_services from .utils import ( @@ -64,6 +64,8 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" + # Initialize domain data structure (setdefault in case discovery already started) + hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) # Only start discovery once regardless of how many entries they have async_setup_services(hass) async_start_discovery(hass) @@ -79,11 +81,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: try: await protect.update() except NotAuthorized as err: - retry_key = f"{entry.entry_id}_auth" - retries = hass.data.setdefault(DOMAIN, {}).get(retry_key, 0) + domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) + retries = domain_data.auth_retries.get(entry.entry_id, 0) if retries < AUTH_RETRIES: retries += 1 - hass.data[DOMAIN][retry_key] = retries + domain_data.auth_retries[entry.entry_id] = retries raise ConfigEntryNotReady from err raise ConfigEntryAuthFailed(err) from err except (TimeoutError, ClientError, ServerDisconnectedError) as err: diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d2f9a8b7b55..d288fe66e2b 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -67,13 +67,11 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", translation_key="is_dark", - icon="mdi:brightness-6", ufp_value="is_dark", ), ProtectBinaryEntityDescription( key="ssh", translation_key="ssh_enabled", - icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="is_ssh_enabled", @@ -82,7 +80,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_status", ufp_value="led_settings.is_enabled", @@ -91,7 +88,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="hdr_mode", translation_key="hdr_mode", - icon="mdi:brightness-7", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_hdr", ufp_value="hdr_mode", @@ -100,7 +96,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="high_fps", translation_key="high_fps", - icon="mdi:video-high-definition", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_highfps", ufp_value="is_high_fps_enabled", @@ -109,7 +104,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="system_sounds", translation_key="system_sounds", - icon="mdi:speaker", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="has_speaker", ufp_value="speaker_settings.are_system_sounds_enabled", @@ -119,7 +113,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="osd_name", translation_key="overlay_show_name", - icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_name_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -127,7 +120,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="osd_date", translation_key="overlay_show_date", - icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_date_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -135,7 +127,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="osd_logo", translation_key="overlay_show_logo", - icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_logo_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -143,7 +134,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="osd_bitrate", translation_key="overlay_show_nerd_mode", - icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_debug_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -151,14 +141,12 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="motion_enabled", translation_key="detections_motion", - icon="mdi:run-fast", ufp_value="recording_settings.enable_motion_detection", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="smart_person", translation_key="detections_person", - icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_person", ufp_value="is_person_detection_on", @@ -167,7 +155,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_vehicle", translation_key="detections_vehicle", - icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", @@ -176,7 +163,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_animal", translation_key="detections_animal", - icon="mdi:paw", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_animal", ufp_value="is_animal_detection_on", @@ -185,7 +171,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_package", translation_key="detections_package", - icon="mdi:package-variant-closed", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_package", ufp_value="is_package_detection_on", @@ -194,7 +179,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_licenseplate", translation_key="detections_license_plate", - icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_license_plate", ufp_value="is_license_plate_detection_on", @@ -203,7 +187,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_smoke", translation_key="detections_smoke", - icon="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_smoke", ufp_value="is_smoke_detection_on", @@ -212,7 +195,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_cmonx", translation_key="detections_co_alarm", - icon="mdi:molecule-co", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_co", ufp_value="is_co_detection_on", @@ -221,7 +203,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_siren", translation_key="detections_siren", - icon="mdi:alarm-bell", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_siren", ufp_value="is_siren_detection_on", @@ -230,7 +211,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_baby_cry", translation_key="detections_baby_cry", - icon="mdi:cradle", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_baby_cry", ufp_value="is_baby_cry_detection_on", @@ -239,7 +219,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_speak", translation_key="detections_speaking", - icon="mdi:account-voice", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_speaking", ufp_value="is_speaking_detection_on", @@ -248,7 +227,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_bark", translation_key="detections_barking", - icon="mdi:dog", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_bark", ufp_value="is_bark_detection_on", @@ -257,7 +235,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_car_alarm", translation_key="detections_car_alarm", - icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_car_alarm", ufp_value="is_car_alarm_detection_on", @@ -266,7 +243,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_car_horn", translation_key="detections_car_horn", - icon="mdi:bugle", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_car_horn", ufp_value="is_car_horn_detection_on", @@ -275,7 +251,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_glass_break", translation_key="detections_glass_break", - icon="mdi:glass-fragile", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_glass_break", ufp_value="is_glass_break_detection_on", @@ -284,7 +259,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="track_person", translation_key="tracking_person", - icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.is_ptz", ufp_value="is_person_tracking_enabled", @@ -296,7 +270,6 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", translation_key="is_dark", - icon="mdi:brightness-6", ufp_value="is_dark", ), ProtectBinaryEntityDescription( @@ -307,7 +280,6 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="light", translation_key="flood_light", - icon="mdi:spotlight-beam", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="is_light_on", ufp_perm=PermRequired.NO_WRITE, @@ -315,7 +287,6 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="ssh", translation_key="ssh_enabled", - icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="is_ssh_enabled", @@ -324,7 +295,6 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_device_settings.is_indicator_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -370,39 +340,34 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="led_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="motion_enabled", - translation_key="detections_motion", - icon="mdi:walk", + translation_key="motion_detection_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="temperature", - translation_key="temperature_sensor", - icon="mdi:thermometer", + translation_key="temperature_sensor_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="temperature_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="humidity", - translation_key="humidity_sensor", - icon="mdi:water-percent", + translation_key="humidity_sensor_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="humidity_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="light", - translation_key="light_sensor", - icon="mdi:brightness-5", + translation_key="light_sensor_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -421,7 +386,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="doorbell", translation_key="doorbell", device_class=BinarySensorDeviceClass.OCCUPANCY, - icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", ufp_event_obj="last_ring_event", ), @@ -434,7 +398,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_any", translation_key="object_detected", - icon="mdi:eye", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", entity_registry_enabled_default=False, @@ -442,7 +405,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_person", translation_key="person_detected", - icon="mdi:walk", ufp_obj_type=SmartDetectObjectType.PERSON, ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", @@ -451,7 +413,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_vehicle", translation_key="vehicle_detected", - icon="mdi:car", ufp_obj_type=SmartDetectObjectType.VEHICLE, ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", @@ -460,7 +421,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_animal", translation_key="animal_detected", - icon="mdi:paw", ufp_obj_type=SmartDetectObjectType.ANIMAL, ufp_required_field="can_detect_animal", ufp_enabled="is_animal_detection_on", @@ -469,7 +429,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_package", translation_key="package_detected", - icon="mdi:package-variant-closed", entity_registry_enabled_default=False, ufp_obj_type=SmartDetectObjectType.PACKAGE, ufp_required_field="can_detect_package", @@ -479,7 +438,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_any", translation_key="audio_object_detected", - icon="mdi:eye", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_audio_detect_event", entity_registry_enabled_default=False, @@ -487,7 +445,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_smoke", translation_key="smoke_alarm_detected", - icon="mdi:fire", ufp_obj_type=SmartDetectObjectType.SMOKE, ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", @@ -496,7 +453,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", translation_key="co_alarm_detected", - icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.CO, ufp_required_field="can_detect_co", ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", @@ -505,7 +462,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_siren", translation_key="siren_detected", - icon="mdi:alarm-bell", ufp_obj_type=SmartDetectObjectType.SIREN, ufp_required_field="can_detect_siren", ufp_enabled="is_siren_detection_on", @@ -514,7 +470,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_baby_cry", translation_key="baby_cry_detected", - icon="mdi:cradle", ufp_obj_type=SmartDetectObjectType.BABY_CRY, ufp_required_field="can_detect_baby_cry", ufp_enabled="is_baby_cry_detection_on", @@ -523,7 +478,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_speak", translation_key="speaking_detected", - icon="mdi:account-voice", ufp_obj_type=SmartDetectObjectType.SPEAK, ufp_required_field="can_detect_speaking", ufp_enabled="is_speaking_detection_on", @@ -532,7 +486,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_bark", translation_key="barking_detected", - icon="mdi:dog", ufp_obj_type=SmartDetectObjectType.BARK, ufp_required_field="can_detect_bark", ufp_enabled="is_bark_detection_on", @@ -541,7 +494,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_car_alarm", translation_key="car_alarm_detected", - icon="mdi:car", ufp_obj_type=SmartDetectObjectType.BURGLAR, ufp_required_field="can_detect_car_alarm", ufp_enabled="is_car_alarm_detection_on", @@ -550,7 +502,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_car_horn", translation_key="car_horn_detected", - icon="mdi:bugle", ufp_obj_type=SmartDetectObjectType.CAR_HORN, ufp_required_field="can_detect_car_horn", ufp_enabled="is_car_horn_detection_on", @@ -559,7 +510,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_glass_break", translation_key="glass_break_detected", - icon="mdi:glass-fragile", ufp_obj_type=SmartDetectObjectType.GLASS_BREAK, ufp_required_field="can_detect_glass_break", ufp_enabled="is_glass_break_detection_on", @@ -577,7 +527,6 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="led_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -588,7 +537,6 @@ VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="ssh", translation_key="ssh_enabled", - icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="is_ssh_enabled", @@ -674,7 +622,7 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): available = self.data.last_update_success # should not be possible since it would require user to - # _downgrade_ to make ustorage disppear + # _downgrade_ to make ustorage disappear assert self.device.system_info.ustorage is not None for disk in self.device.system_info.ustorage.disks: if disk.slot == slot: diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index a8e0e034710..67356fcf862 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from dataclasses import dataclass from functools import partial import logging -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING from uiprotect.data import ModelType, ProtectAdoptableDeviceModel @@ -27,7 +27,7 @@ from .entity import ( PermRequired, ProtectDeviceEntity, ProtectEntityDescription, - ProtectSetableKeysMixin, + ProtectSettableKeysMixin, T, async_all_device_entities, ) @@ -38,16 +38,13 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class ProtectButtonEntityDescription( - ProtectSetableKeysMixin[T], ButtonEntityDescription + ProtectSettableKeysMixin[T], ButtonEntityDescription ): """Describes UniFi Protect Button entity.""" ufp_press: str | None = None -DEVICE_CLASS_CHIME_BUTTON: Final = "unifiprotect__chime_button" - - ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="reboot", @@ -60,7 +57,6 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( key="unadopt", translation_key="unadopt_device", entity_registry_enabled_default=False, - icon="mdi:delete", ufp_press="unadopt", ufp_perm=PermRequired.DELETE, ), @@ -69,7 +65,6 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ADOPT_BUTTON = ProtectButtonEntityDescription[ProtectAdoptableDeviceModel]( key="adopt", translation_key="adopt_device", - icon="mdi:plus-circle", ufp_press="adopt", ) @@ -77,7 +72,6 @@ SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="clear_tamper", translation_key="clear_tamper", - icon="mdi:notification-clear-all", ufp_press="clear_tamper", ufp_perm=PermRequired.WRITE, ), @@ -87,14 +81,11 @@ CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="play", translation_key="play_chime", - device_class=DEVICE_CLASS_CHIME_BUTTON, - icon="mdi:play", ufp_press="play", ), ProtectButtonEntityDescription( key="play_buzzer", translation_key="play_buzzer", - icon="mdi:play", ufp_press="play_buzzer", ), ) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index e3462a5e0d7..8a35c6d6aa1 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -114,7 +114,7 @@ def _async_camera_entities( hass, entry, data, ufp_device ): # do not enable streaming for package camera - # 2 FPS causes a lot of buferring + # 2 FPS causes a lot of buffering entities.append( ProtectCamera( data, diff --git a/homeassistant/components/unifiprotect/discovery.py b/homeassistant/components/unifiprotect/discovery.py index 860ebeb2787..3a7fb7c65e0 100644 --- a/homeassistant/components/unifiprotect/discovery.py +++ b/homeassistant/components/unifiprotect/discovery.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import asdict +from dataclasses import asdict, dataclass, field from datetime import timedelta import logging from typing import Any @@ -13,22 +13,34 @@ from homeassistant import config_entries from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DISCOVERY = "discovery" + +@dataclass +class UniFiProtectRuntimeData: + """Runtime data stored in hass.data[DOMAIN].""" + + auth_retries: dict[str, int] = field(default_factory=dict) + discovery_started: bool = False + + +# Typed key for hass.data access at DOMAIN level +DATA_UNIFIPROTECT: HassKey[UniFiProtectRuntimeData] = HassKey(DOMAIN) + DISCOVERY_INTERVAL = timedelta(minutes=60) @callback def async_start_discovery(hass: HomeAssistant) -> None: """Start discovery.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - if DISCOVERY in domain_data: + domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) + if domain_data.discovery_started: return - domain_data[DISCOVERY] = True + domain_data.discovery_started = True async def _async_discovery() -> None: async_trigger_discovery(hass, await async_discover_devices()) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 90804559297..35d750c2d8d 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -83,7 +83,7 @@ def _async_device_entities( _LOGGER.debug( "Adding %s entity %s for %s", klass.__name__, - description.name, + description.key, device.display_name, ) continue @@ -111,7 +111,7 @@ def _async_device_entities( _LOGGER.debug( "Adding %s entity %s for %s", klass.__name__, - description.name, + description.key, device.display_name, ) @@ -252,16 +252,11 @@ class BaseProtectEntity(Entity): if changed: if _LOGGER.isEnabledFor(logging.DEBUG): - device_name = device.name or "" - if hasattr(self, "entity_description") and self.entity_description.name: - device_name += f" {self.entity_description.name}" - _LOGGER.debug( - "Updating state [%s (%s)] %s -> %s", - device_name, - device.mac, + "Updating state [%s] %s -> %s", + self.entity_id, previous_attrs, - tuple((getattr(self, attr)) for attr in self._state_attrs), + tuple(getter() for getter in self._state_getters), ) self.async_write_ha_state() @@ -319,7 +314,8 @@ class ProtectNVREntity(BaseProtectEntity): identifiers={(DOMAIN, self.device.mac)}, manufacturer=DEFAULT_BRAND, name=self.device.display_name, - model=self.device.type, + model=self.device.market_name or self.device.type, + model_id=self.device.type, sw_version=str(self.device.version), configuration_url=self.device.api.base_url, ) @@ -438,7 +434,7 @@ class ProtectEventMixin(ProtectEntityDescription[T]): @dataclass(frozen=True, kw_only=True) -class ProtectSetableKeysMixin(ProtectEntityDescription[T]): +class ProtectSettableKeysMixin(ProtectEntityDescription[T]): """Mixin for settable values.""" ufp_set_method: str | None = None diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 1c2cb2cdd53..2bc563bd682 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -365,7 +365,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( key="doorbell", translation_key="doorbell", device_class=EventDeviceClass.DOORBELL, - icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", ufp_event_obj="last_ring_event", event_types=[EVENT_TYPE_DOORBELL_RING], @@ -374,7 +373,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="nfc", translation_key="nfc", - icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", event_types=[EVENT_TYPE_NFC_SCANNED], @@ -383,7 +381,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", event_types=[ @@ -395,7 +392,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="vehicle", translation_key="vehicle", - icon="mdi:car", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", event_types=[EVENT_TYPE_VEHICLE_DETECTED], diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index a3c489aa88b..9fccfcf97ac 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -1,4 +1,437 @@ { + "entity": { + "binary_sensor": { + "alarm_sound_detection": { + "default": "mdi:alarm-bell" + }, + "animal_detected": { + "default": "mdi:paw" + }, + "audio_object_detected": { + "default": "mdi:eye" + }, + "baby_cry_detected": { + "default": "mdi:cradle" + }, + "barking_detected": { + "default": "mdi:dog" + }, + "car_alarm_detected": { + "default": "mdi:car" + }, + "car_horn_detected": { + "default": "mdi:bugle" + }, + "co_alarm_detected": { + "default": "mdi:molecule-co" + }, + "detections_animal": { + "default": "mdi:paw" + }, + "detections_baby_cry": { + "default": "mdi:cradle" + }, + "detections_barking": { + "default": "mdi:dog" + }, + "detections_car_alarm": { + "default": "mdi:car" + }, + "detections_car_horn": { + "default": "mdi:bugle" + }, + "detections_co_alarm": { + "default": "mdi:molecule-co" + }, + "detections_glass_break": { + "default": "mdi:glass-fragile" + }, + "detections_license_plate": { + "default": "mdi:car" + }, + "detections_motion": { + "default": "mdi:run-fast" + }, + "detections_package": { + "default": "mdi:package-variant-closed" + }, + "detections_person": { + "default": "mdi:walk" + }, + "detections_siren": { + "default": "mdi:alarm-bell" + }, + "detections_smoke": { + "default": "mdi:fire" + }, + "detections_speaking": { + "default": "mdi:account-voice" + }, + "detections_vehicle": { + "default": "mdi:car" + }, + "doorbell": { + "default": "mdi:doorbell-video" + }, + "flood_light": { + "default": "mdi:spotlight-beam" + }, + "glass_break_detected": { + "default": "mdi:glass-fragile" + }, + "hdr_mode": { + "default": "mdi:brightness-7" + }, + "high_fps": { + "default": "mdi:video-high-definition" + }, + "humidity_sensor": { + "default": "mdi:water-percent" + }, + "humidity_sensor_enabled": { + "default": "mdi:water-percent" + }, + "is_dark": { + "default": "mdi:brightness-6" + }, + "light_sensor": { + "default": "mdi:brightness-5" + }, + "light_sensor_enabled": { + "default": "mdi:brightness-5" + }, + "motion_detection_enabled": { + "default": "mdi:walk" + }, + "object_detected": { + "default": "mdi:eye" + }, + "overlay_show_date": { + "default": "mdi:fullscreen" + }, + "overlay_show_logo": { + "default": "mdi:fullscreen" + }, + "overlay_show_name": { + "default": "mdi:fullscreen" + }, + "overlay_show_nerd_mode": { + "default": "mdi:fullscreen" + }, + "package_detected": { + "default": "mdi:package-variant-closed" + }, + "person_detected": { + "default": "mdi:walk" + }, + "siren_detected": { + "default": "mdi:alarm-bell" + }, + "smoke_alarm_detected": { + "default": "mdi:fire" + }, + "speaking_detected": { + "default": "mdi:account-voice" + }, + "ssh_enabled": { + "default": "mdi:lock" + }, + "status_light": { + "default": "mdi:led-on" + }, + "system_sounds": { + "default": "mdi:speaker" + }, + "temperature_sensor": { + "default": "mdi:thermometer" + }, + "temperature_sensor_enabled": { + "default": "mdi:thermometer" + }, + "tracking_person": { + "default": "mdi:walk" + }, + "vehicle_detected": { + "default": "mdi:car" + } + }, + "button": { + "adopt_device": { + "default": "mdi:plus-circle" + }, + "clear_tamper": { + "default": "mdi:notification-clear-all" + }, + "play_buzzer": { + "default": "mdi:play" + }, + "play_chime": { + "default": "mdi:play" + }, + "unadopt_device": { + "default": "mdi:delete" + } + }, + "event": { + "doorbell": { + "default": "mdi:doorbell-video" + }, + "fingerprint": { + "default": "mdi:fingerprint" + }, + "nfc": { + "default": "mdi:nfc" + }, + "vehicle": { + "default": "mdi:car" + } + }, + "number": { + "auto_lock_timeout": { + "default": "mdi:walk" + }, + "auto_shutoff_duration": { + "default": "mdi:camera-timer" + }, + "chime_duration": { + "default": "mdi:bell" + }, + "doorbell_ring_volume": { + "default": "mdi:bell-ring" + }, + "infrared_custom_lux_trigger": { + "default": "mdi:white-balance-sunny" + }, + "microphone_level": { + "default": "mdi:microphone" + }, + "motion_sensitivity": { + "default": "mdi:walk" + }, + "system_sounds_volume": { + "default": "mdi:volume-high" + }, + "volume": { + "default": "mdi:speaker" + }, + "wide_dynamic_range": { + "default": "mdi:state-machine" + }, + "zoom_level": { + "default": "mdi:magnify-plus-outline" + } + }, + "select": { + "chime_type": { + "default": "mdi:bell" + }, + "doorbell_text": { + "default": "mdi:card-text" + }, + "hdr_mode": { + "default": "mdi:brightness-7" + }, + "infrared_mode": { + "default": "mdi:circle-opacity" + }, + "light_mode": { + "default": "mdi:spotlight" + }, + "liveview": { + "default": "mdi:view-dashboard" + }, + "mount_type": { + "default": "mdi:screwdriver" + }, + "paired_camera": { + "default": "mdi:cctv" + }, + "recording_mode": { + "default": "mdi:video-outline" + } + }, + "sensor": { + "chime_type": { + "default": "mdi:bell" + }, + "cpu_utilization": { + "default": "mdi:speedometer" + }, + "doorbell_text": { + "default": "mdi:card-text" + }, + "infrared_mode": { + "default": "mdi:circle-opacity" + }, + "last_doorbell_ring": { + "default": "mdi:doorbell-video" + }, + "last_ring": { + "default": "mdi:bell" + }, + "lens_type": { + "default": "mdi:camera-iris" + }, + "light_mode": { + "default": "mdi:spotlight" + }, + "liveview": { + "default": "mdi:view-dashboard" + }, + "memory_utilization": { + "default": "mdi:memory" + }, + "microphone_level": { + "default": "mdi:microphone" + }, + "motion_sensitivity": { + "default": "mdi:walk" + }, + "mount_type": { + "default": "mdi:screwdriver" + }, + "paired_camera": { + "default": "mdi:cctv" + }, + "recording_capacity": { + "default": "mdi:record-rec" + }, + "recording_mode": { + "default": "mdi:video-outline" + }, + "resolution_4k_video": { + "default": "mdi:cctv" + }, + "resolution_free_space": { + "default": "mdi:cctv" + }, + "resolution_hd_video": { + "default": "mdi:cctv" + }, + "sensitivity": { + "default": "mdi:walk" + }, + "storage_utilization": { + "default": "mdi:harddisk" + }, + "type_continuous_video": { + "default": "mdi:server" + }, + "type_detections_video": { + "default": "mdi:server" + }, + "type_timelapse_video": { + "default": "mdi:server" + }, + "uptime": { + "default": "mdi:clock" + }, + "volume": { + "default": "mdi:speaker" + } + }, + "switch": { + "analytics_enabled": { + "default": "mdi:google-analytics" + }, + "color_night_vision": { + "default": "mdi:light-flood-down" + }, + "detections_animal": { + "default": "mdi:paw" + }, + "detections_baby_cry": { + "default": "mdi:cradle" + }, + "detections_bark": { + "default": "mdi:dog" + }, + "detections_car_alarm": { + "default": "mdi:car" + }, + "detections_car_horn": { + "default": "mdi:bugle" + }, + "detections_co_alarm": { + "default": "mdi:molecule-co" + }, + "detections_glass_break": { + "default": "mdi:glass-fragile" + }, + "detections_license_plate": { + "default": "mdi:car" + }, + "detections_motion": { + "default": "mdi:walk" + }, + "detections_package": { + "default": "mdi:package-variant-closed" + }, + "detections_person": { + "default": "mdi:walk" + }, + "detections_siren": { + "default": "mdi:alarm-bell" + }, + "detections_smoke": { + "default": "mdi:fire" + }, + "detections_speak": { + "default": "mdi:account-voice" + }, + "detections_vehicle": { + "default": "mdi:car" + }, + "hdr_mode": { + "default": "mdi:brightness-7" + }, + "high_fps": { + "default": "mdi:video-high-definition" + }, + "humidity_sensor": { + "default": "mdi:water-percent" + }, + "insights_enabled": { + "default": "mdi:magnify" + }, + "light_sensor": { + "default": "mdi:brightness-5" + }, + "motion": { + "default": "mdi:run-fast" + }, + "overlay_show_date": { + "default": "mdi:fullscreen" + }, + "overlay_show_logo": { + "default": "mdi:fullscreen" + }, + "overlay_show_name": { + "default": "mdi:fullscreen" + }, + "overlay_show_nerd_mode": { + "default": "mdi:fullscreen" + }, + "privacy_mode": { + "default": "mdi:eye-settings" + }, + "ssh_enabled": { + "default": "mdi:lock" + }, + "status_light": { + "default": "mdi:led-on" + }, + "system_sounds": { + "default": "mdi:speaker" + }, + "temperature_sensor": { + "default": "mdi:thermometer" + }, + "tracking_person": { + "default": "mdi:walk" + } + } + }, "services": { "add_doorbell_text": { "service": "mdi:message-plus" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 2db0861e91e..23de97bef16 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -24,7 +24,7 @@ from .entity import ( PermRequired, ProtectDeviceEntity, ProtectEntityDescription, - ProtectSetableKeysMixin, + ProtectSettableKeysMixin, T, async_all_device_entities, ) @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class ProtectNumberEntityDescription( - ProtectSetableKeysMixin[T], NumberEntityDescription + ProtectSettableKeysMixin[T], NumberEntityDescription ): """Describes UniFi Protect Number entity.""" @@ -67,7 +67,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="wdr_value", translation_key="wide_dynamic_range", - icon="mdi:state-machine", entity_category=EntityCategory.CONFIG, ufp_min=0, ufp_max=3, @@ -80,7 +79,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="mic_level", translation_key="microphone_level", - icon="mdi:microphone", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, @@ -95,7 +93,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="system_sounds_volume", translation_key="system_sounds_volume", - icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, @@ -110,7 +107,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="doorbell_ring_volume", translation_key="doorbell_ring_volume", - icon="mdi:bell-ring", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, @@ -125,7 +121,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="zoom_position", translation_key="zoom_level", - icon="mdi:magnify-plus-outline", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, @@ -139,7 +134,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="chime_duration", translation_key="chime_duration", - icon="mdi:bell", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, ufp_min=1, @@ -154,7 +148,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="icr_lux", translation_key="infrared_custom_lux_trigger", - icon="mdi:white-balance-sunny", entity_category=EntityCategory.CONFIG, ufp_min=0, ufp_max=30, @@ -171,7 +164,6 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="sensitivity", translation_key="motion_sensitivity", - icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, @@ -185,7 +177,6 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription[Light]( key="duration", translation_key="auto_shutoff_duration", - icon="mdi:camera-timer", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, ufp_min=15, @@ -202,7 +193,6 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="sensitivity", translation_key="motion_sensitivity", - icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, @@ -219,7 +209,6 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription[Doorlock]( key="auto_lock_time", translation_key="auto_lock_timeout", - icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, ufp_min=0, @@ -236,7 +225,6 @@ CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="volume", translation_key="volume", - icon="mdi:speaker", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, ufp_min=0, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 76ecca18781..ad19a79086f 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum import logging -from typing import Any, Final +from typing import Any from uiprotect.api import ProtectApiClient from uiprotect.data import ( @@ -37,7 +37,7 @@ from .entity import ( PermRequired, ProtectDeviceEntity, ProtectEntityDescription, - ProtectSetableKeysMixin, + ProtectSettableKeysMixin, T, async_all_device_entities, ) @@ -102,12 +102,10 @@ DEVICE_RECORDING_MODES = [ {"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode) ] -DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" - @dataclass(frozen=True, kw_only=True) class ProtectSelectEntityDescription( - ProtectSetableKeysMixin[T], SelectEntityDescription + ProtectSettableKeysMixin[T], SelectEntityDescription ): """Describes UniFi Protect Select entity.""" @@ -195,7 +193,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="recording_mode", translation_key="recording_mode", - icon="mdi:video-outline", entity_category=EntityCategory.CONFIG, ufp_options=DEVICE_RECORDING_MODES, ufp_enum_type=RecordingMode, @@ -206,7 +203,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="infrared", translation_key="infrared_mode", - icon="mdi:circle-opacity", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_ir", ufp_options=INFRARED_MODES, @@ -218,9 +214,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Camera]( key="doorbell_text", translation_key="doorbell_text", - icon="mdi:card-text", entity_category=EntityCategory.CONFIG, - device_class=DEVICE_CLASS_LCD_MESSAGE, ufp_required_field="feature_flags.has_lcd_screen", ufp_value_fn=_get_doorbell_current, ufp_options_fn=_get_doorbell_options, @@ -230,7 +224,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="chime_type", translation_key="chime_type", - icon="mdi:bell", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_chime", ufp_options=CHIME_TYPES, @@ -242,7 +235,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="hdr_mode", translation_key="hdr_mode", - icon="mdi:brightness-7", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", ufp_options=HDR_MODES, @@ -256,7 +248,6 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Light]( key=_KEY_LIGHT_MOTION, translation_key="light_mode", - icon="mdi:spotlight", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, ufp_value_fn=async_get_light_motion_current, @@ -266,7 +257,6 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Light]( key="paired_camera", translation_key="paired_camera", - icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, @@ -279,7 +269,6 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="mount_type", translation_key="mount_type", - icon="mdi:screwdriver", entity_category=EntityCategory.CONFIG, ufp_options=MOUNT_TYPES, ufp_enum_type=MountType, @@ -290,7 +279,6 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Sensor]( key="paired_camera", translation_key="paired_camera", - icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, @@ -303,7 +291,6 @@ DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Doorlock]( key="paired_camera", translation_key="paired_camera", - icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, @@ -316,7 +303,6 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Viewer]( key="viewer", translation_key="liveview", - icon="mdi:view-dashboard", entity_category=None, ufp_options_fn=_get_viewer_options, ufp_value_fn=_get_viewer_current, @@ -388,9 +374,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): entity_description.entity_category is not None and entity_description.ufp_options_fn is not None ): - _LOGGER.debug( - "Updating dynamic select options for %s", entity_description.name - ) + _LOGGER.debug("Updating dynamic select options for %s", self.entity_id) self._async_set_options(self.data, entity_description) if (unifi_value := entity_description.get_ufp_value(device)) is None: unifi_value = TYPE_EMPTY_VALUE diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 86d9897ef48..ec24491deee 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -126,7 +126,6 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", translation_key="uptime", - icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -215,7 +214,6 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="doorbell_last_trip_time", translation_key="last_doorbell_ring", device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", ufp_value="last_ring", entity_registry_enabled_default=False, @@ -224,14 +222,12 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="lens_type", translation_key="lens_type", entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:camera-iris", ufp_required_field="has_removable_lens", ufp_value="feature_flags.lens_type", ), ProtectSensorEntityDescription( key="mic_level", translation_key="microphone_level", - icon="mdi:microphone", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="has_mic", @@ -242,7 +238,6 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="recording_mode", translation_key="recording_mode", - icon="mdi:video-outline", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="recording_settings.mode.value", ufp_perm=PermRequired.NO_WRITE, @@ -250,7 +245,6 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="infrared", translation_key="infrared_mode", - icon="mdi:circle-opacity", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_ir", ufp_value="isp_settings.ir_led_mode.value", @@ -259,7 +253,6 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="doorbell_text", translation_key="doorbell_text", - icon="mdi:card-text", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_lcd_screen", ufp_value="lcd_message.text", @@ -268,7 +261,6 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="chime_type", translation_key="chime_type", - icon="mdi:bell", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ufp_required_field="feature_flags.has_chime", @@ -366,7 +358,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="sensitivity", translation_key="sensitivity", - icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.sensitivity", @@ -375,7 +366,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="mount_type", translation_key="mount_type", - icon="mdi:screwdriver", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", ufp_perm=PermRequired.NO_WRITE, @@ -383,7 +373,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="paired_camera", translation_key="paired_camera", - icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, @@ -402,7 +391,6 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="paired_camera", translation_key="paired_camera", - icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, @@ -413,7 +401,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", translation_key="uptime", - icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=_get_uptime, @@ -422,7 +409,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="storage_utilization", translation_key="storage_utilization", native_unit_of_measurement=PERCENTAGE, - icon="mdi:harddisk", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.utilization", @@ -432,7 +418,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="record_rotating", translation_key="type_timelapse_video", native_unit_of_measurement=PERCENTAGE, - icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.timelapse_recordings.percentage", @@ -442,7 +427,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="record_timelapse", translation_key="type_continuous_video", native_unit_of_measurement=PERCENTAGE, - icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.continuous_recordings.percentage", @@ -452,7 +436,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="record_detections", translation_key="type_detections_video", native_unit_of_measurement=PERCENTAGE, - icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.detections_recordings.percentage", @@ -462,7 +445,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="resolution_HD", translation_key="resolution_hd_video", native_unit_of_measurement=PERCENTAGE, - icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.hd_usage.percentage", @@ -472,7 +454,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="resolution_4K", translation_key="resolution_4k_video", native_unit_of_measurement=PERCENTAGE, - icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.uhd_usage.percentage", @@ -482,7 +463,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="resolution_free", translation_key="resolution_free_space", native_unit_of_measurement=PERCENTAGE, - icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.free.percentage", @@ -492,7 +472,6 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="record_capacity", translation_key="recording_capacity", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:record-rec", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value_fn=_get_nvr_recording_capacity, @@ -504,7 +483,6 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="cpu_utilization", translation_key="cpu_utilization", native_unit_of_measurement=PERCENTAGE, - icon="mdi:speedometer", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -524,7 +502,6 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="memory_utilization", translation_key="memory_utilization", native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -544,7 +521,6 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="sensitivity", translation_key="motion_sensitivity", - icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_device_settings.pir_sensitivity", @@ -553,7 +529,6 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription[Light]( key="light_motion", translation_key="light_mode", - icon="mdi:spotlight", entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=async_get_light_motion_current, ufp_perm=PermRequired.NO_WRITE, @@ -561,7 +536,6 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="paired_camera", translation_key="paired_camera", - icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, @@ -583,13 +557,11 @@ CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="last_ring", translation_key="last_ring", device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:bell", ufp_value="last_ring", ), ProtectSensorEntityDescription( key="volume", translation_key="volume", - icon="mdi:speaker", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="volume", @@ -601,7 +573,6 @@ VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="viewer", translation_key="liveview", - icon="mdi:view-dashboard", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="liveview.name", ufp_perm=PermRequired.NO_WRITE, diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 005d0a03ac3..0acb98e5aa5 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.target import ( - TargetSelectorData, + TargetSelection, async_extract_referenced_entity_ids, ) from homeassistant.util.json import JsonValueType @@ -55,7 +55,7 @@ SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone" SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells" SERVICE_GET_USER_KEYRING_INFO = "get_user_keyring_info" -ALL_GLOBAL_SERIVCES = [ +ALL_GLOBAL_SERVICES = [ SERVICE_ADD_DOORBELL_TEXT, SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_SET_CHIME_PAIRED, @@ -117,7 +117,7 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl @callback def _async_get_ufp_camera(call: ServiceCall) -> Camera: - ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data)) + ref = async_extract_referenced_entity_ids(call.hass, TargetSelection(call.data)) entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() @@ -135,7 +135,7 @@ def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]: return { _async_get_ufp_instance(call.hass, device_id) for device_id in async_extract_referenced_entity_ids( - call.hass, TargetSelectorData(call.data) + call.hass, TargetSelection(call.data) ).referenced_devices } @@ -207,7 +207,7 @@ def _async_unique_id_to_mac(unique_id: str) -> str: async def set_chime_paired_doorbells(call: ServiceCall) -> None: """Set paired doorbells on chime.""" - ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data)) + ref = async_extract_referenced_entity_ids(call.hass, TargetSelection(call.data)) entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() @@ -223,7 +223,7 @@ async def set_chime_paired_doorbells(call: ServiceCall) -> None: call.data = ReadOnlyDict(call.data.get("doorbells") or {}) doorbell_refs = async_extract_referenced_entity_ids( - call.hass, TargetSelectorData(call.data) + call.hass, TargetSelection(call.data) ) doorbell_ids: set[str] = set() for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced: diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index b620c195fc2..57d32e24993 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -29,8 +29,6 @@ set_chime_paired_doorbells: selector: device: integration: unifiprotect - entity: - device_class: unifiprotect__chime_button doorbells: example: "binary_sensor.front_doorbell_doorbell" required: false diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index ccdba79b1bd..70f1cbaed44 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -119,49 +119,49 @@ "name": "Contact" }, "detections_animal": { - "name": "Detections: animal" + "name": "Animal detection" }, "detections_baby_cry": { - "name": "Detections: baby cry" + "name": "Baby cry detection" }, "detections_barking": { - "name": "Detections: barking" + "name": "Barking detection" }, "detections_car_alarm": { - "name": "Detections: car alarm" + "name": "Car alarm detection" }, "detections_car_horn": { - "name": "Detections: car horn" + "name": "Car horn detection" }, "detections_co_alarm": { - "name": "Detections: CO alarm" + "name": "CO alarm detection" }, "detections_glass_break": { - "name": "Detections: glass break" + "name": "Glass break detection" }, "detections_license_plate": { - "name": "Detections: license plate" + "name": "License plate detection" }, "detections_motion": { - "name": "Detections: motion" + "name": "Motion detection" }, "detections_package": { - "name": "Detections: package" + "name": "Package detection" }, "detections_person": { - "name": "Detections: person" + "name": "Person detection" }, "detections_siren": { - "name": "Detections: siren" + "name": "Siren detection" }, "detections_smoke": { - "name": "Detections: smoke" + "name": "Smoke detection" }, "detections_speaking": { - "name": "Detections: speaking" + "name": "Speaking detection" }, "detections_vehicle": { - "name": "Detections: vehicle" + "name": "Vehicle detection" }, "doorbell": { "name": "[%key:component::event::entity_component::doorbell::name%]" @@ -181,12 +181,21 @@ "humidity_sensor": { "name": "Humidity sensor" }, + "humidity_sensor_enabled": { + "name": "Humidity sensor enabled" + }, "is_dark": { "name": "Is dark" }, "light_sensor": { "name": "Light sensor" }, + "light_sensor_enabled": { + "name": "Light sensor enabled" + }, + "motion_detection_enabled": { + "name": "Motion detection enabled" + }, "object_detected": { "name": "Object detected" }, @@ -229,6 +238,9 @@ "temperature_sensor": { "name": "Temperature sensor" }, + "temperature_sensor_enabled": { + "name": "Temperature sensor enabled" + }, "tracking_person": { "name": "Tracking: person" }, diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index c6dd7359ba4..a40c071cc8b 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -29,7 +29,7 @@ from .entity import ( ProtectEntityDescription, ProtectIsOnEntity, ProtectNVREntity, - ProtectSetableKeysMixin, + ProtectSettableKeysMixin, T, async_all_device_entities, ) @@ -41,7 +41,7 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class ProtectSwitchEntityDescription( - ProtectSetableKeysMixin[T], SwitchEntityDescription + ProtectSettableKeysMixin[T], SwitchEntityDescription ): """Describes UniFi Protect Switch entity.""" @@ -54,7 +54,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", translation_key="ssh_enabled", - icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", @@ -64,7 +63,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", ufp_value="led_settings.is_enabled", @@ -74,7 +72,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="hdr_mode", translation_key="hdr_mode", - icon="mdi:brightness-7", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ufp_required_field="feature_flags.has_hdr", @@ -85,7 +82,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription[Camera]( key="high_fps", translation_key="high_fps", - icon="mdi:video-high-definition", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_highfps", ufp_value="is_high_fps_enabled", @@ -95,7 +91,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="system_sounds", translation_key="system_sounds", - icon="mdi:speaker", entity_category=EntityCategory.CONFIG, ufp_required_field="has_speaker", ufp_value="speaker_settings.are_system_sounds_enabled", @@ -106,7 +101,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="osd_name", translation_key="overlay_show_name", - icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", ufp_set_method="set_osd_name", @@ -115,7 +109,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="osd_date", translation_key="overlay_show_date", - icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", ufp_set_method="set_osd_date", @@ -124,7 +117,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="osd_logo", translation_key="overlay_show_logo", - icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", ufp_set_method="set_osd_logo", @@ -133,7 +125,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="osd_bitrate", translation_key="overlay_show_nerd_mode", - icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", ufp_set_method="set_osd_bitrate", @@ -142,7 +133,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="color_night_vision", translation_key="color_night_vision", - icon="mdi:light-flood-down", entity_category=EntityCategory.CONFIG, ufp_required_field="has_color_night_vision", ufp_value="isp_settings.is_color_night_vision_enabled", @@ -152,7 +142,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="motion", translation_key="motion", - icon="mdi:run-fast", entity_category=EntityCategory.CONFIG, ufp_value="recording_settings.enable_motion_detection", ufp_enabled="is_recording_enabled", @@ -162,7 +151,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_person", translation_key="detections_person", - icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_person", ufp_value="is_person_detection_on", @@ -173,7 +161,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_vehicle", translation_key="detections_vehicle", - icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", @@ -184,7 +171,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_animal", translation_key="detections_animal", - icon="mdi:paw", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_animal", ufp_value="is_animal_detection_on", @@ -195,7 +181,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_package", translation_key="detections_package", - icon="mdi:package-variant-closed", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_package", ufp_value="is_package_detection_on", @@ -206,7 +191,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_licenseplate", translation_key="detections_license_plate", - icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_license_plate", ufp_value="is_license_plate_detection_on", @@ -217,7 +201,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_smoke", translation_key="detections_smoke", - icon="mdi:fire", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_smoke", ufp_value="is_smoke_detection_on", @@ -228,7 +211,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_cmonx", translation_key="detections_co_alarm", - icon="mdi:molecule-co", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_co", ufp_value="is_co_detection_on", @@ -239,7 +221,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_siren", translation_key="detections_siren", - icon="mdi:alarm-bell", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_siren", ufp_value="is_siren_detection_on", @@ -250,7 +231,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_baby_cry", translation_key="detections_baby_cry", - icon="mdi:cradle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_baby_cry", ufp_value="is_baby_cry_detection_on", @@ -261,7 +241,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_speak", translation_key="detections_speak", - icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_speaking", ufp_value="is_speaking_detection_on", @@ -272,7 +251,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_bark", translation_key="detections_bark", - icon="mdi:dog", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_bark", ufp_value="is_bark_detection_on", @@ -283,7 +261,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_car_alarm", translation_key="detections_car_alarm", - icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_alarm", ufp_value="is_car_alarm_detection_on", @@ -294,7 +271,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_car_horn", translation_key="detections_car_horn", - icon="mdi:bugle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_horn", ufp_value="is_car_horn_detection_on", @@ -305,7 +281,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="smart_glass_break", translation_key="detections_glass_break", - icon="mdi:glass-fragile", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_glass_break", ufp_value="is_glass_break_detection_on", @@ -316,7 +291,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="track_person", translation_key="tracking_person", - icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.is_ptz", ufp_value="is_person_tracking_enabled", @@ -328,7 +302,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( key="privacy_mode", translation_key="privacy_mode", - icon="mdi:eye-settings", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_privacy_mask", ufp_value="is_privacy_on", @@ -339,7 +312,6 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", @@ -348,7 +320,6 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="motion", translation_key="detections_motion", - icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_value="motion_settings.is_enabled", ufp_set_method="set_motion_status", @@ -357,7 +328,6 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="temperature", translation_key="temperature_sensor", - icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ufp_value="temperature_settings.is_enabled", ufp_set_method="set_temperature_status", @@ -366,7 +336,6 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="humidity", translation_key="humidity_sensor", - icon="mdi:water-percent", entity_category=EntityCategory.CONFIG, ufp_value="humidity_settings.is_enabled", ufp_set_method="set_humidity_status", @@ -375,7 +344,6 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="light", translation_key="light_sensor", - icon="mdi:brightness-5", entity_category=EntityCategory.CONFIG, ufp_value="light_settings.is_enabled", ufp_set_method="set_light_status", @@ -396,7 +364,6 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", translation_key="ssh_enabled", - icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", @@ -406,7 +373,6 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="light_device_settings.is_indicator_enabled", ufp_set_method="set_status_light", @@ -418,7 +384,6 @@ DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", translation_key="status_light", - icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", @@ -430,7 +395,6 @@ VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", translation_key="ssh_enabled", - icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", @@ -443,7 +407,6 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="analytics_enabled", translation_key="analytics_enabled", - icon="mdi:google-analytics", entity_category=EntityCategory.CONFIG, ufp_value="is_analytics_enabled", ufp_set_method="set_anonymous_analytics", @@ -451,7 +414,6 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="insights_enabled", translation_key="insights_enabled", - icon="mdi:magnify", entity_category=EntityCategory.CONFIG, ufp_value="is_insights_enabled", ufp_set_method="set_insights", @@ -552,7 +514,7 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): await self.device.set_privacy(False, prev_mic, prev_record) async def async_added_to_hass(self) -> None: - """Restore extra state attributes on startp up.""" + """Restore extra state attributes on startup.""" await super().async_added_to_hass() if not (last_state := await self.async_get_last_state()): return diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 3dfb6118eb0..5f651861a74 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -22,7 +22,7 @@ from .entity import ( PermRequired, ProtectDeviceEntity, ProtectEntityDescription, - ProtectSetableKeysMixin, + ProtectSettableKeysMixin, T, async_all_device_entities, ) @@ -31,7 +31,7 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class ProtectTextEntityDescription(ProtectSetableKeysMixin[T], TextEntityDescription): +class ProtectTextEntityDescription(ProtectSettableKeysMixin[T], TextEntityDescription): """Describes UniFi Protect Text entity.""" diff --git a/homeassistant/components/update/icons.json b/homeassistant/components/update/icons.json index 89af07de67f..3ed26f4b6bd 100644 --- a/homeassistant/components/update/icons.json +++ b/homeassistant/components/update/icons.json @@ -17,5 +17,10 @@ "skip": { "service": "mdi:package-check" } + }, + "triggers": { + "update_became_available": { + "trigger": "mdi:package-up" + } } } diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 13639a164ea..fa226ec1408 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted updates to become available.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "extra_fields": { "for": "[%key:common::device_automation::extra_fields::for%]" @@ -55,6 +59,15 @@ "name": "Firmware" } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "clear_skipped": { "description": "Removes the skipped version marker from an update.", @@ -79,5 +92,17 @@ "name": "Skip update" } }, - "title": "Update" + "title": "Update", + "triggers": { + "update_became_available": { + "description": "Triggers after one or more updates become available.", + "fields": { + "behavior": { + "description": "[%key:component::update::common::trigger_behavior_description%]", + "name": "[%key:component::update::common::trigger_behavior_name%]" + } + }, + "name": "Update became available" + } + } } diff --git a/homeassistant/components/update/trigger.py b/homeassistant/components/update/trigger.py new file mode 100644 index 00000000000..bd258e5498b --- /dev/null +++ b/homeassistant/components/update/trigger.py @@ -0,0 +1,16 @@ +"""Provides triggers for update platform.""" + +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +from .const import DOMAIN + +TRIGGERS: dict[str, type[Trigger]] = { + "update_became_available": make_entity_target_state_trigger(DOMAIN, STATE_ON), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for update platform.""" + return TRIGGERS diff --git a/homeassistant/components/update/triggers.yaml b/homeassistant/components/update/triggers.yaml new file mode 100644 index 00000000000..e4a276dd38e --- /dev/null +++ b/homeassistant/components/update/triggers.yaml @@ -0,0 +1,17 @@ +.trigger_common: &trigger_common + target: + entity: + domain: update + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +update_became_available: *trigger_common diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 9211356a27a..67033f058da 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/vacuum/trigger.py b/homeassistant/components/vacuum/trigger.py index b0857d7f694..50ca8af7d47 100644 --- a/homeassistant/components/vacuum/trigger.py +++ b/homeassistant/components/vacuum/trigger.py @@ -1,15 +1,17 @@ """Provides triggers for vacuum cleaners.""" from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger from .const import DOMAIN, VacuumActivity TRIGGERS: dict[str, type[Trigger]] = { - "docked": make_entity_state_trigger(DOMAIN, VacuumActivity.DOCKED), - "errored": make_entity_state_trigger(DOMAIN, VacuumActivity.ERROR), - "paused_cleaning": make_entity_state_trigger(DOMAIN, VacuumActivity.PAUSED), - "started_cleaning": make_entity_state_trigger(DOMAIN, VacuumActivity.CLEANING), + "docked": make_entity_target_state_trigger(DOMAIN, VacuumActivity.DOCKED), + "errored": make_entity_target_state_trigger(DOMAIN, VacuumActivity.ERROR), + "paused_cleaning": make_entity_target_state_trigger(DOMAIN, VacuumActivity.PAUSED), + "started_cleaning": make_entity_target_state_trigger( + DOMAIN, VacuumActivity.CLEANING + ), } diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 055fd5e2277..f78d3655d54 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import CONF_VLP_FILE, DOMAIN from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -98,8 +98,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool: """Establish connection with velbus.""" controller = Velbus( - entry.data[CONF_PORT], + dsn=entry.data[CONF_PORT], cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), + vlp_file=entry.data.get(CONF_VLP_FILE), ) try: await controller.connect() diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 7c93d8784ad..a413a41a127 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -2,18 +2,35 @@ from __future__ import annotations -from typing import Any +from pathlib import Path +import shutil +from typing import Any, Final import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed +from velbusaio.vlp_reader import VlpFile import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import selector from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import CONF_TLS, DOMAIN +from .const import CONF_TLS, CONF_VLP_FILE, DOMAIN + +STORAGE_PATH: Final = ".storage/velbus.{key}.vlp" + + +class InvalidVlpFile(HomeAssistantError): + """Error to indicate that the uploaded file is not a valid VLP file.""" class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): @@ -24,14 +41,15 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the velbus config flow.""" - self._errors: dict[str, str] = {} self._device: str = "" + self._vlp_file: str | None = None self._title: str = "" def _create_device(self) -> ConfigFlowResult: """Create an entry async.""" return self.async_create_entry( - title=self._title, data={CONF_PORT: self._device} + title=self._title, + data={CONF_PORT: self._device, CONF_VLP_FILE: self._vlp_file}, ) async def _test_connection(self) -> bool: @@ -41,7 +59,6 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): await controller.connect() await controller.stop() except VelbusConnectionFailed: - self._errors[CONF_PORT] = "cannot_connect" return False return True @@ -57,6 +74,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle network step.""" + step_errors: dict[str, str] = {} if user_input is not None: self._title = "Velbus Network" if user_input[CONF_TLS]: @@ -68,7 +86,8 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" self._async_abort_entries_match({CONF_PORT: self._device}) if await self._test_connection(): - return self._create_device() + return await self.async_step_vlp() + step_errors[CONF_HOST] = "cannot_connect" else: user_input = { CONF_TLS: True, @@ -88,13 +107,14 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): ), suggested_values=user_input, ), - errors=self._errors, + errors=step_errors, ) async def async_step_usbselect( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle usb select step.""" + step_errors: dict[str, str] = {} ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) list_of_ports = [ f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" @@ -107,7 +127,8 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device self._async_abort_entries_match({CONF_PORT: self._device}) if await self._test_connection(): - return self._create_device() + return await self.async_step_vlp() + step_errors[CONF_PORT] = "cannot_connect" else: user_input = {} user_input[CONF_PORT] = "" @@ -118,7 +139,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}), suggested_values=user_input, ), - errors=self._errors, + errors=step_errors, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: @@ -144,3 +165,75 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): step_id="discovery_confirm", description_placeholders={CONF_NAME: self._title}, ) + + async def _validate_vlp_file(self, file_path: str) -> None: + """Validate VLP file and raise exception if invalid.""" + vlpfile = VlpFile(file_path) + await vlpfile.read() + if not vlpfile.get(): + raise InvalidVlpFile("no_modules") + + async def async_step_vlp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step when user wants to use the VLP file.""" + step_errors: dict[str, str] = {} + if user_input is not None: + if CONF_VLP_FILE not in user_input or user_input[CONF_VLP_FILE] == "": + # The VLP file is optional, so allow skipping it + self._vlp_file = None + else: + try: + # handle the file upload + self._vlp_file = await self.hass.async_add_executor_job( + save_uploaded_vlp_file, self.hass, user_input[CONF_VLP_FILE] + ) + # validate it + await self._validate_vlp_file(self._vlp_file) + except InvalidVlpFile as e: + step_errors[CONF_VLP_FILE] = str(e) + if self.source == SOURCE_RECONFIGURE: + old_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + old_entry, + data={ + CONF_VLP_FILE: self._vlp_file, + CONF_PORT: old_entry.data.get(CONF_PORT), + }, + ) + if not step_errors: + return self._create_device() + + return self.async_show_form( + step_id="vlp", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_VLP_FILE): selector.FileSelector( + config=selector.FileSelectorConfig(accept=".vlp") + ), + } + ), + suggested_values=user_input, + ), + errors=step_errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_vlp() + + +def save_uploaded_vlp_file(hass: HomeAssistant, uploaded_file_id: str) -> str: + """Validate the uploaded file and move it to the storage directory. + + Blocking function needs to be called in executor. + """ + + with process_uploaded_file(hass, uploaded_file_id) as file: + dest_path = Path(hass.config.path(STORAGE_PATH.format(key=uploaded_file_id))) + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(file, dest_path) + return str(dest_path) diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 7223e83ddf4..06030472d82 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -14,6 +14,7 @@ DOMAIN: Final = "velbus" CONF_CONFIG_ENTRY: Final = "config_entry" CONF_MEMO_TEXT: Final = "memo_text" CONF_TLS: Final = "tls" +CONF_VLP_FILE: Final = "vlp_file" SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index d4e1a794fa4..776117e894c 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -3,7 +3,7 @@ "name": "Velbus", "codeowners": ["@Cereal2nd", "@brefra"], "config_flow": true, - "dependencies": ["usb"], + "dependencies": ["usb", "file_upload"], "documentation": "https://www.home-assistant.io/integrations/velbus", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index dd52c3a417e..cdbe7328955 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -1,11 +1,13 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_modules": "No Velbus modules found, please check your VLP file." }, "step": { "network": { @@ -41,6 +43,16 @@ "usbselect": "Via USB device" }, "title": "Define the Velbus connection" + }, + "vlp": { + "data": { + "vlp_file": "Path to VLP file" + }, + "data_description": { + "vlp_file": "Select the VLP file from your filesystem." + }, + "description": "You can optionally provide a VLP file to improve module detection. The VLP file is the config file from VelbusLink that contains all module information. If you do not provide it now, you can always add it later in the integration options. Without this file, Home Assistant will try to detect the modules automatically, but this can take longer time and some modules might not be detected correctly.", + "title": "Optional VLP file" } } }, diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 8687d6760ec..594affd9539 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, issue_registry as ir from .const import DOMAIN, LOGGER, PLATFORMS @@ -25,14 +26,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo password = entry.data[CONF_PASSWORD] pyvlx = PyVLX(host=host, password=password) - LOGGER.debug("Velux interface started") + LOGGER.debug("Setting up Velux gateway %s", host) try: + LOGGER.debug("Retrieving scenes from %s", host) await pyvlx.load_scenes() + LOGGER.debug("Retrieving nodes from %s", host) await pyvlx.load_nodes() - except PyVLXException as ex: - LOGGER.exception("Can't connect to velux interface: %s", ex) - return False + except (OSError, PyVLXException) as ex: + # Defer setup and retry later as the bridge is not ready/available + raise ConfigEntryNotReady( + f"Unable to connect to Velux gateway at {host}. " + "If connection continues to fail, try power-cycling the gateway device." + ) from ex + LOGGER.debug("Velux connection to %s successful", host) entry.runtime_data = pyvlx connections = None diff --git a/homeassistant/components/velux/quality_scale.yaml b/homeassistant/components/velux/quality_scale.yaml index 7f96dc77537..5895a83909a 100644 --- a/homeassistant/components/velux/quality_scale.yaml +++ b/homeassistant/components/velux/quality_scale.yaml @@ -20,9 +20,7 @@ rules: has-entity-name: done runtime-data: done test-before-configure: done - test-before-setup: - status: todo - comment: needs rework, failure to setup currently only returns false + test-before-setup: done unique-config-entry: done # Silver diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index 4f075a57228..4bc20eb3ff1 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.webhook import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN @@ -50,7 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> ) watergate_client = WatergateLocalApiClient( - sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}" + base_url=( + sonic_address + if sonic_address.startswith("http") + else f"http://{sonic_address}" + ), + session=async_get_clientsession(hass), ) coordinator = WatergateDataCoordinator(hass, entry, watergate_client) diff --git a/homeassistant/components/watergate/config_flow.py b/homeassistant/components/watergate/config_flow.py index de8494053a3..df52852f0be 100644 --- a/homeassistant/components/watergate/config_flow.py +++ b/homeassistant/components/watergate/config_flow.py @@ -11,6 +11,7 @@ from watergate_local_api.watergate_api import ( from homeassistant.components.webhook import async_generate_id as webhook_generate_id from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -34,7 +35,8 @@ class WatergateConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: watergate_client = WatergateLocalApiClient( - self.prepare_ip_address(user_input[CONF_IP_ADDRESS]) + base_url=self.prepare_ip_address(user_input[CONF_IP_ADDRESS]), + session=async_get_clientsession(self.hass), ) try: state = await watergate_client.async_get_device_state() diff --git a/homeassistant/components/watergate/manifest.json b/homeassistant/components/watergate/manifest.json index 80db25e8241..25abe1d59b0 100644 --- a/homeassistant/components/watergate/manifest.json +++ b/homeassistant/components/watergate/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/watergate", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["watergate-local-api==2025.1.0"] } diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml index 73a39bd5264..f2d058f1062 100644 --- a/homeassistant/components/watergate/quality_scale.yaml +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -27,9 +27,12 @@ rules: # Silver config-entry-unloading: done - log-when-unavailable: todo + log-when-unavailable: done entity-unavailable: done - action-exceptions: done + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. reauthentication-flow: status: exempt comment: | @@ -37,5 +40,36 @@ rules: parallel-updates: done test-coverage: done integration-owner: done - docs-installation-parameters: todo - docs-configuration-parameters: todo + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + + # Gold + devices: done + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py new file mode 100644 index 00000000000..8cbc90548b1 --- /dev/null +++ b/homeassistant/components/watts/__init__.py @@ -0,0 +1,160 @@ +"""The Watts Vision + integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from http import HTTPStatus +import logging + +from aiohttp import ClientError, ClientResponseError +from visionpluspython.auth import WattsVisionAuth +from visionpluspython.client import WattsVisionClient +from visionpluspython.models import ThermostatDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DOMAIN +from .coordinator import ( + WattsVisionHubCoordinator, + WattsVisionThermostatCoordinator, + WattsVisionThermostatData, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +@dataclass +class WattsVisionRuntimeData: + """Runtime data for Watts Vision integration.""" + + auth: WattsVisionAuth + hub_coordinator: WattsVisionHubCoordinator + thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] + client: WattsVisionClient + + +type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + + +@callback +def _handle_new_thermostats( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + hub_coordinator: WattsVisionHubCoordinator, +) -> None: + """Check for new thermostat devices and create coordinators.""" + + current_device_ids = set(hub_coordinator.data.keys()) + known_device_ids = set(entry.runtime_data.thermostat_coordinators.keys()) + new_device_ids = current_device_ids - known_device_ids + + if not new_device_ids: + return + + _LOGGER.info("Discovered %d new device(s): %s", len(new_device_ids), new_device_ids) + + thermostat_coordinators = entry.runtime_data.thermostat_coordinators + client = entry.runtime_data.client + + for device_id in new_device_ids: + device = hub_coordinator.data[device_id] + if not isinstance(device, ThermostatDevice): + continue + + thermostat_coordinator = WattsVisionThermostatCoordinator( + hass, client, entry, hub_coordinator, device_id + ) + thermostat_coordinator.async_set_updated_data( + WattsVisionThermostatData(thermostat=device) + ) + thermostat_coordinators[device_id] = thermostat_coordinator + + _LOGGER.debug("Created thermostat coordinator for device %s", device_id) + + async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_new_device") + + +async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) -> bool: + """Set up Watts Vision from a config entry.""" + + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + "OAuth2 implementation temporarily unavailable" + ) from err + + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + try: + await oauth_session.async_ensure_token_valid() + except ClientResponseError as err: + if HTTPStatus.BAD_REQUEST <= err.status < HTTPStatus.INTERNAL_SERVER_ERROR: + raise ConfigEntryAuthFailed("OAuth session not valid") from err + raise ConfigEntryNotReady("Temporary connection error") from err + except ClientError as err: + raise ConfigEntryNotReady("Network issue during OAuth setup") from err + + session = aiohttp_client.async_get_clientsession(hass) + auth = WattsVisionAuth( + oauth_session=oauth_session, + session=session, + ) + + client = WattsVisionClient(auth, session) + hub_coordinator = WattsVisionHubCoordinator(hass, client, entry) + + await hub_coordinator.async_config_entry_first_refresh() + + thermostat_coordinators: dict[str, WattsVisionThermostatCoordinator] = {} + for device_id in hub_coordinator.device_ids: + device = hub_coordinator.data[device_id] + if not isinstance(device, ThermostatDevice): + continue + + thermostat_coordinator = WattsVisionThermostatCoordinator( + hass, client, entry, hub_coordinator, device_id + ) + thermostat_coordinator.async_set_updated_data( + WattsVisionThermostatData(thermostat=device) + ) + thermostat_coordinators[device_id] = thermostat_coordinator + + entry.runtime_data = WattsVisionRuntimeData( + auth=auth, + hub_coordinator=hub_coordinator, + thermostat_coordinators=thermostat_coordinators, + client=client, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Listener for dynamic device detection + entry.async_on_unload( + hub_coordinator.async_add_listener( + lambda: _handle_new_thermostats(hass, entry, hub_coordinator) + ) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: WattsVisionConfigEntry +) -> bool: + """Unload a config entry.""" + for thermostat_coordinator in entry.runtime_data.thermostat_coordinators.values(): + thermostat_coordinator.unsubscribe_hub_listener() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/watts/application_credentials.py b/homeassistant/components/watts/application_credentials.py new file mode 100644 index 00000000000..0203d77ad1a --- /dev/null +++ b/homeassistant/components/watts/application_credentials.py @@ -0,0 +1,12 @@ +"""Application credentials for Watts integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + + return AuthorizationServer(authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN) diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py new file mode 100644 index 00000000000..e9f21b974f5 --- /dev/null +++ b/homeassistant/components/watts/climate.py @@ -0,0 +1,164 @@ +"""Climate platform for Watts Vision integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from visionpluspython.models import ThermostatDevice + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WattsVisionConfigEntry +from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC +from .coordinator import WattsVisionThermostatCoordinator +from .entity import WattsVisionThermostatEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Watts Vision climate entities from a config entry.""" + + thermostat_coordinators = entry.runtime_data.thermostat_coordinators + known_device_ids: set[str] = set() + + @callback + def _check_new_thermostats() -> None: + """Check for new thermostat devices.""" + current_device_ids = set(thermostat_coordinators.keys()) + new_device_ids = current_device_ids - known_device_ids + + if not new_device_ids: + return + + _LOGGER.debug( + "Adding climate entities for %d new thermostat(s)", + len(new_device_ids), + ) + + new_entities = [ + WattsVisionClimate( + thermostat_coordinators[device_id], + thermostat_coordinators[device_id].data.thermostat, + ) + for device_id in new_device_ids + ] + + known_device_ids.update(new_device_ids) + async_add_entities(new_entities) + + _check_new_thermostats() + + # Listen for new thermostats + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{entry.entry_id}_new_device", + _check_new_thermostats, + ) + ) + + +class WattsVisionClimate(WattsVisionThermostatEntity, ClimateEntity): + """Representation of a Watts Vision heater as a climate entity.""" + + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_name = None + + def __init__( + self, + coordinator: WattsVisionThermostatCoordinator, + thermostat: ThermostatDevice, + ) -> None: + """Initialize the climate entity.""" + + super().__init__(coordinator, thermostat.device_id) + + self._attr_min_temp = thermostat.min_allowed_temperature + self._attr_max_temp = thermostat.max_allowed_temperature + + if thermostat.temperature_unit.upper() == "C": + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + else: + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.thermostat.current_temperature + + @property + def target_temperature(self) -> float | None: + """Return the temperature setpoint.""" + return self.thermostat.setpoint + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + return THERMOSTAT_MODE_TO_HVAC.get(self.thermostat.thermostat_mode) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + try: + await self.coordinator.client.set_thermostat_temperature( + self.device_id, temperature + ) + except RuntimeError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_error", + ) from err + + _LOGGER.debug( + "Successfully set temperature to %s for %s", + temperature, + self.device_id, + ) + + self.coordinator.trigger_fast_polling() + + await self.coordinator.async_refresh() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode] + + try: + await self.coordinator.client.set_thermostat_mode(self.device_id, mode) + except (ValueError, RuntimeError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_hvac_mode_error", + ) from err + + _LOGGER.debug( + "Successfully set HVAC mode to %s (ThermostatMode.%s) for %s", + hvac_mode, + mode.name, + self.device_id, + ) + + self.coordinator.trigger_fast_polling() + + await self.coordinator.async_refresh() diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py new file mode 100644 index 00000000000..c71e67528aa --- /dev/null +++ b/homeassistant/components/watts/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for Watts Vision integration.""" + +import logging +from typing import Any + +from visionpluspython.auth import WattsVisionAuth + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Watts Vision OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra parameters for OAuth2 authentication.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the OAuth2 flow.""" + + access_token = data["token"]["access_token"] + user_id = WattsVisionAuth.extract_user_id_from_token(access_token) + + if not user_id: + return self.async_abort(reason="invalid_token") + + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Watts Vision +", + data=data, + ) diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py new file mode 100644 index 00000000000..8434daca11d --- /dev/null +++ b/homeassistant/components/watts/const.py @@ -0,0 +1,37 @@ +"""Constants for the Watts Vision+ integration.""" + +from visionpluspython.models import ThermostatMode + +from homeassistant.components.climate import HVACMode + +DOMAIN = "watts" + +OAUTH2_AUTHORIZE = "https://visionlogin.b2clogin.com/visionlogin.onmicrosoft.com/B2C_1A_VISION_UNIFIEDSIGNUPORSIGNIN/oauth2/v2.0/authorize" +OAUTH2_TOKEN = "https://visionlogin.b2clogin.com/visionlogin.onmicrosoft.com/B2C_1A_VISION_UNIFIEDSIGNUPORSIGNIN/oauth2/v2.0/token" + +OAUTH2_SCOPES = [ + "openid", + "offline_access", + "https://visionlogin.onmicrosoft.com/homeassistant-api/homeassistant.read", +] + +# Update intervals +UPDATE_INTERVAL_SECONDS = 30 +FAST_POLLING_INTERVAL_SECONDS = 5 +DISCOVERY_INTERVAL_MINUTES = 15 + +# Mapping from Watts Vision + modes to Home Assistant HVAC modes + +THERMOSTAT_MODE_TO_HVAC = { + "Program": HVACMode.AUTO, + "Eco": HVACMode.HEAT, + "Comfort": HVACMode.HEAT, + "Off": HVACMode.OFF, +} + +# Mapping from Home Assistant HVAC modes to Watts Vision + modes +HVAC_MODE_TO_THERMOSTAT = { + HVACMode.HEAT: ThermostatMode.COMFORT, + HVACMode.OFF: ThermostatMode.OFF, + HVACMode.AUTO: ThermostatMode.PROGRAM, +} diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py new file mode 100644 index 00000000000..5dbb5571c63 --- /dev/null +++ b/homeassistant/components/watts/coordinator.py @@ -0,0 +1,228 @@ +"""Data coordinator for Watts Vision integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import TYPE_CHECKING + +from visionpluspython.client import WattsVisionClient +from visionpluspython.exceptions import ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionDeviceError, + WattsVisionError, + WattsVisionTimeoutError, +) +from visionpluspython.models import Device, ThermostatDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + DISCOVERY_INTERVAL_MINUTES, + DOMAIN, + FAST_POLLING_INTERVAL_SECONDS, + UPDATE_INTERVAL_SECONDS, +) + +if TYPE_CHECKING: + from . import WattsVisionRuntimeData + + type WattsVisionConfigEntry = ConfigEntry[WattsVisionRuntimeData] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WattsVisionThermostatData: + """Data class for thermostat device coordinator.""" + + thermostat: ThermostatDevice + + +class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]): + """Hub coordinator for bulk device discovery and updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: WattsVisionClient, + config_entry: WattsVisionConfigEntry, + ) -> None: + """Initialize the hub coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), + config_entry=config_entry, + ) + self.client = client + self._last_discovery: datetime | None = None + self.previous_devices: set[str] = set() + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch data and periodic device discovery.""" + now = datetime.now() + is_first_refresh = self._last_discovery is None + discovery_interval_elapsed = ( + self._last_discovery is not None + and now - self._last_discovery + >= timedelta(minutes=DISCOVERY_INTERVAL_MINUTES) + ) + + if is_first_refresh or discovery_interval_elapsed: + try: + devices_list = await self.client.discover_devices() + except WattsVisionAuthError as err: + raise ConfigEntryAuthFailed("Authentication failed") from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as err: + if is_first_refresh: + raise ConfigEntryNotReady("Failed to discover devices") from err + _LOGGER.warning( + "Periodic discovery failed: %s, falling back to update", err + ) + else: + self._last_discovery = now + devices = {device.device_id: device for device in devices_list} + + current_devices = set(devices.keys()) + if stale_devices := self.previous_devices - current_devices: + await self._remove_stale_devices(stale_devices) + + self.previous_devices = current_devices + return devices + + # Regular update of existing devices + device_ids = list(self.data.keys()) + if not device_ids: + return {} + + try: + devices = await self.client.get_devices_report(device_ids) + except WattsVisionAuthError as err: + raise ConfigEntryAuthFailed("Authentication failed") from err + except ( + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as err: + raise UpdateFailed("Failed to update devices") from err + + _LOGGER.debug("Updated %d devices", len(devices)) + return devices + + async def _remove_stale_devices(self, stale_device_ids: set[str]) -> None: + """Remove stale devices.""" + assert self.config_entry is not None + device_registry = dr.async_get(self.hass) + + for device_id in stale_device_ids: + _LOGGER.info("Removing stale device: %s", device_id) + + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + @property + def device_ids(self) -> list[str]: + """Get list of all device IDs.""" + return list((self.data or {}).keys()) + + +class WattsVisionThermostatCoordinator( + DataUpdateCoordinator[WattsVisionThermostatData] +): + """Thermostat device coordinator for individual updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: WattsVisionClient, + config_entry: WattsVisionConfigEntry, + hub_coordinator: WattsVisionHubCoordinator, + device_id: str, + ) -> None: + """Initialize the thermostat coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device_id}", + update_interval=None, # Manual refresh only + config_entry=config_entry, + ) + self.client = client + self.device_id = device_id + self.hub_coordinator = hub_coordinator + self._fast_polling_until: datetime | None = None + + # Listen to hub coordinator updates + self.unsubscribe_hub_listener = hub_coordinator.async_add_listener( + self._handle_hub_update + ) + + def _handle_hub_update(self) -> None: + """Handle updates from hub coordinator.""" + if self.hub_coordinator.data and self.device_id in self.hub_coordinator.data: + device = self.hub_coordinator.data[self.device_id] + assert isinstance(device, ThermostatDevice) + self.async_set_updated_data(WattsVisionThermostatData(thermostat=device)) + + async def _async_update_data(self) -> WattsVisionThermostatData: + """Refresh specific thermostat device.""" + if self._fast_polling_until and datetime.now() > self._fast_polling_until: + self._fast_polling_until = None + self.update_interval = None + _LOGGER.debug( + "Device %s: Fast polling period ended, returning to manual refresh", + self.device_id, + ) + + try: + device = await self.client.get_device(self.device_id, refresh=True) + except ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionTimeoutError, + WattsVisionDeviceError, + WattsVisionError, + ConnectionError, + TimeoutError, + ValueError, + ) as err: + raise UpdateFailed(f"Failed to refresh device {self.device_id}") from err + + if not device: + raise UpdateFailed(f"Device {self.device_id} not found") + + assert isinstance(device, ThermostatDevice) + _LOGGER.debug("Refreshed thermostat %s", self.device_id) + return WattsVisionThermostatData(thermostat=device) + + def trigger_fast_polling(self, duration: int = 60) -> None: + """Activate fast polling for a specified duration after a command.""" + self._fast_polling_until = datetime.now() + timedelta(seconds=duration) + self.update_interval = timedelta(seconds=FAST_POLLING_INTERVAL_SECONDS) + _LOGGER.debug( + "Device %s: Activated fast polling for %d seconds", self.device_id, duration + ) diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py new file mode 100644 index 00000000000..4b429cf4c55 --- /dev/null +++ b/homeassistant/components/watts/entity.py @@ -0,0 +1,43 @@ +"""Base entity for Watts Vision integration.""" + +from __future__ import annotations + +from visionpluspython.models import ThermostatDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WattsVisionThermostatCoordinator + + +class WattsVisionThermostatEntity(CoordinatorEntity[WattsVisionThermostatCoordinator]): + """Base entity for Watts Vision thermostat devices.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: WattsVisionThermostatCoordinator, device_id: str + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator, context=device_id) + self.device_id = device_id + self._attr_unique_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device_id)}, + name=self.thermostat.device_name, + manufacturer="Watts", + model=f"Vision+ {self.thermostat.device_type}", + suggested_area=self.thermostat.room_name, + ) + + @property + def thermostat(self) -> ThermostatDevice: + """Return the thermostat device from the coordinator data.""" + return self.coordinator.data.thermostat + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.data.thermostat.is_online diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json new file mode 100644 index 00000000000..40bcf375760 --- /dev/null +++ b/homeassistant/components/watts/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "watts", + "name": "Watts Vision +", + "codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/watts", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["visionpluspython==1.0.2"] +} diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml new file mode 100644 index 00000000000..152dcbbd3f5 --- /dev/null +++ b/homeassistant/components/watts/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not have configuration parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Device doesn't have discoverable properties + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: No entity required translations. + exception-translations: todo + icon-translations: + status: exempt + comment: Thermostat entities use standard HA Climate entity. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No actionable repair scenarios, auth issues are handled by reauthentication flow. + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json new file mode 100644 index 00000000000..967a1167f8f --- /dev/null +++ b/homeassistant/components/watts/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "invalid_token": "The provided access token is invalid.", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + } + }, + "exceptions": { + "set_hvac_mode_error": { + "message": "An error occurred while setting the HVAC mode." + }, + "set_temperature_error": { + "message": "An error occurred while setting the temperature." + } + } +} diff --git a/homeassistant/components/websocket_api/automation.py b/homeassistant/components/websocket_api/automation.py index b9db16db0bb..5efd6de792a 100644 --- a/homeassistant/components/websocket_api/automation.py +++ b/homeassistant/components/websocket_api/automation.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass +from enum import StrEnum import logging from typing import Any, Self @@ -33,6 +34,21 @@ FLATTENED_SERVICE_DESCRIPTIONS_CACHE: HassKey[ tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]] ] = HassKey("websocket_automation_flat_service_description_cache") +AUTOMATION_COMPONENT_LOOKUP_CACHE: HassKey[ + dict[ + AutomationComponentType, + tuple[Mapping[str, Any], _AutomationComponentLookupTable], + ] +] = HassKey("websocket_automation_component_lookup_cache") + + +class AutomationComponentType(StrEnum): + """Types of automation components.""" + + TRIGGERS = "triggers" + CONDITIONS = "conditions" + SERVICES = "services" + @dataclass(slots=True, kw_only=True) class _EntityFilter: @@ -107,6 +123,14 @@ class _AutomationComponentLookupData: ) +@dataclass(slots=True, kw_only=True) +class _AutomationComponentLookupTable: + """Helper class for looking up automation components.""" + + domain_components: dict[str | None, list[_AutomationComponentLookupData]] + component_count: int + + def _get_automation_component_domains( target_description: dict[str, Any], ) -> set[str | None]: @@ -138,8 +162,52 @@ def _get_automation_component_domains( return domains +def _get_automation_component_lookup_table( + hass: HomeAssistant, + component_type: AutomationComponentType, + component_descriptions: Mapping[str, Mapping[str, Any] | None], +) -> _AutomationComponentLookupTable: + """Get a dict of automation components keyed by domain, along with the total number of components. + + Returns a cached object if available. + """ + + try: + cache = hass.data[AUTOMATION_COMPONENT_LOOKUP_CACHE] + except KeyError: + cache = hass.data[AUTOMATION_COMPONENT_LOOKUP_CACHE] = {} + + if (cached := cache.get(component_type)) is not None: + cached_descriptions, cached_lookup = cached + if cached_descriptions is component_descriptions: + return cached_lookup + + _LOGGER.debug( + "Automation component lookup data for %s has no cache yet", component_type + ) + + lookup_table = _AutomationComponentLookupTable( + domain_components={}, component_count=0 + ) + for component, description in component_descriptions.items(): + if description is None or CONF_TARGET not in description: + _LOGGER.debug("Skipping component %s without target description", component) + continue + domains = _get_automation_component_domains(description[CONF_TARGET]) + lookup_data = _AutomationComponentLookupData.create( + component, description[CONF_TARGET] + ) + for domain in domains: + lookup_table.domain_components.setdefault(domain, []).append(lookup_data) + lookup_table.component_count += 1 + + cache[component_type] = (component_descriptions, lookup_table) + return lookup_table + + def _async_get_automation_components_for_target( hass: HomeAssistant, + component_type: AutomationComponentType, target_selection: ConfigType, expand_group: bool, component_descriptions: Mapping[str, Mapping[str, Any] | None], @@ -150,32 +218,22 @@ def _async_get_automation_components_for_target( """ extracted = target_helpers.async_extract_referenced_entity_ids( hass, - target_helpers.TargetSelectorData(target_selection), + target_helpers.TargetSelection(target_selection), expand_group=expand_group, ) _LOGGER.debug("Extracted entities for lookup: %s", extracted) - # Build lookup structure: domain -> list of trigger/condition/service lookup data - domain_components: dict[str | None, list[_AutomationComponentLookupData]] = {} - component_count = 0 - for component, description in component_descriptions.items(): - if description is None or CONF_TARGET not in description: - _LOGGER.debug("Skipping component %s without target description", component) - continue - domains = _get_automation_component_domains(description[CONF_TARGET]) - lookup_data = _AutomationComponentLookupData.create( - component, description[CONF_TARGET] - ) - for domain in domains: - domain_components.setdefault(domain, []).append(lookup_data) - component_count += 1 - - _LOGGER.debug("Automation components per domain: %s", domain_components) + lookup_table = _get_automation_component_lookup_table( + hass, component_type, component_descriptions + ) + _LOGGER.debug( + "Automation components per domain: %s", lookup_table.domain_components + ) entity_infos = entity_sources(hass) matched_components: set[str] = set() for entity_id in extracted.referenced | extracted.indirectly_referenced: - if component_count == len(matched_components): + if lookup_table.component_count == len(matched_components): # All automation components matched already, so we don't need to iterate further break @@ -187,7 +245,11 @@ def _async_get_automation_components_for_target( entity_domain = entity_id.split(".")[0] entity_integration = entity_info["domain"] for domain in (entity_domain, entity_integration, None): - for component_data in domain_components.get(domain, []): + if not ( + domain_component_data := lookup_table.domain_components.get(domain) + ): + continue + for component_data in domain_component_data: if component_data.component in matched_components: continue if component_data.matches( @@ -204,7 +266,11 @@ async def async_get_triggers_for_target( """Get triggers for a target.""" descriptions = await async_get_all_trigger_descriptions(hass) return _async_get_automation_components_for_target( - hass, target_selector, expand_group, descriptions + hass, + AutomationComponentType.TRIGGERS, + target_selector, + expand_group, + descriptions, ) @@ -214,7 +280,11 @@ async def async_get_conditions_for_target( """Get conditions for a target.""" descriptions = await async_get_all_condition_descriptions(hass) return _async_get_automation_components_for_target( - hass, target_selector, expand_group, descriptions + hass, + AutomationComponentType.CONDITIONS, + target_selector, + expand_group, + descriptions, ) @@ -247,5 +317,9 @@ async def async_get_services_for_target( return flattened_descriptions return _async_get_automation_components_for_target( - hass, target_selector, expand_group, get_flattened_service_descriptions() + hass, + AutomationComponentType.SERVICES, + target_selector, + expand_group, + get_flattened_service_descriptions(), ) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 040811bca43..4302949f10b 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -865,9 +865,9 @@ def handle_extract_from_target( ) -> None: """Handle extract from target command.""" - selector_data = target_helpers.TargetSelectorData(msg["target"]) + target_selection = target_helpers.TargetSelection(msg["target"]) extracted = target_helpers.async_extract_referenced_entity_ids( - hass, selector_data, expand_group=msg["expand_group"] + hass, target_selection, expand_group=msg["expand_group"] ) extracted_dict = { diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 91705428945..04dd3d2c036 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -244,9 +244,7 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda oven, cavity: ( - temp if (temp := oven.get_temp(cavity)) != 0 else None - ), + value_fn=lambda oven, cavity: oven.get_temp(cavity), ), WhirlpoolOvenCavitySensorEntityDescription( key="oven_target_temperature", @@ -254,9 +252,7 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda oven, cavity: ( - temp if (temp := oven.get_target_temp(cavity)) != 0 else None - ), + value_fn=lambda oven, cavity: oven.get_target_temp(cavity), ), ) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 9338c28da1c..bea4af3627a 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -259,10 +259,16 @@ class WithingsWebhookManager: self.hass, self.entry.data[CONF_WEBHOOK_ID] ) url = URL(webhook_url) - if url.scheme != "https" or url.port != 443: + if url.scheme != "https": LOGGER.warning( - "Webhook not registered - " - "https and port 443 is required to register the webhook" + "Webhook not registered - HTTPS is required. " + "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" + ) + return + if url.port != 443: + LOGGER.warning( + "Webhook not registered - port 443 is required. " + "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" ) return diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 337d608ae11..d7aab20583e 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -6,6 +6,7 @@ from typing import Any import voluptuous as vol from wled import WLED, Device, WLEDConnectionError, WLEDUnsupportedVersionError +import yarl from homeassistant.components import onboarding from homeassistant.config_entries import ( @@ -24,6 +25,15 @@ from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN from .coordinator import WLEDConfigEntry +def _normalize_host(host: str) -> str: + """Normalize host by extracting hostname if a URL is provided.""" + try: + return yarl.URL(host).host or host + except ValueError: + pass + return host + + class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a WLED config flow.""" @@ -46,8 +56,9 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: + host = _normalize_host(user_input[CONF_HOST]) try: - device = await self._async_get_device(user_input[CONF_HOST]) + device = await self._async_get_device(host) except WLEDUnsupportedVersionError: errors["base"] = "unsupported_version" except WLEDConnectionError: @@ -67,16 +78,12 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_update_reload_and_abort( entry, - data_updates=user_input, + data_updates={CONF_HOST: host}, ) - self._abort_if_unique_id_configured( - updates={CONF_HOST: user_input[CONF_HOST]} - ) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self.async_create_entry( title=device.info.name, - data={ - CONF_HOST: user_input[CONF_HOST], - }, + data={CONF_HOST: host}, ) data_schema = vol.Schema({vol.Required(CONF_HOST): str}) if self.source == SOURCE_RECONFIGURE: diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index 49197ed7d26..fd44a454164 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wolf_client = WolfClient( username, password, - client=get_async_client(hass=hass, verify_ssl=False), + client=create_async_httpx_client(hass=hass, verify_ssl=False, timeout=20), ) parameters = await fetch_parameters_init(wolf_client, gateway_id, device_id) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 7e3d8ee192b..ff67d631e82 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fabaff", "@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/workday", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 73d3e435432..0b5bca4cd57 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -4,10 +4,22 @@ from __future__ import annotations import logging +from httpx import HTTPStatusError, RequestError, TimeoutException +from pythonxbox.api.client import XboxLiveClient + +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.httpx_client import get_async_client +from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import ( XboxConfigEntry, @@ -41,34 +53,105 @@ async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_migrate_unique_id(hass, entry) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener(hass: HomeAssistant, entry: XboxConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_unique_id(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: - """Migrate config entry. +async def async_migrate_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: + """Migrate config entry.""" - Migration requires runtime data - """ + if entry.version == 1 and entry.minor_version < 3: + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from e + session = OAuth2Session(hass, entry, implementation) + async_session = get_async_client(hass) + auth = AsyncConfigEntryAuth(async_session, session) + await auth.refresh_tokens() + client = XboxLiveClient(auth) - if entry.version == 1 and entry.minor_version < 2: - # Migrate unique_id from `xbox` to account xuid and - # change generic entry name to user's gamertag - coordinator = entry.runtime_data.status - xuid = coordinator.client.xuid - gamertag = coordinator.data.presence[xuid].gamertag + if entry.minor_version < 2: + # Migrate unique_id from `xbox` to account xuid and + # change generic entry name to user's gamertag + try: + own = await client.people.get_friends_by_xuid(client.xuid) + except TimeoutException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e - return hass.config_entries.async_update_entry( - entry, - unique_id=xuid, - title=(gamertag if entry.title == "Home Assistant Cloud" else entry.title), - minor_version=2, - ) + hass.config_entries.async_update_entry( + entry, + unique_id=client.xuid, + title=( + own.people[0].gamertag + if entry.title == "Home Assistant Cloud" + else entry.title + ), + minor_version=2, + ) + if entry.minor_version < 3: + # Migrate favorite friends to friend subentries + try: + friends = await client.people.get_friends_own() + except TimeoutException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e + dev_reg = dr.async_get(hass) + for friend in friends.people: + if not friend.is_favorite: + continue + subentry = ConfigSubentry( + subentry_type="friend", + title=friend.gamertag, + unique_id=friend.xuid, + data={}, # type: ignore[arg-type] + ) + hass.config_entries.async_add_subentry(entry, subentry) + + if device := dev_reg.async_get_device({(DOMAIN, friend.xuid)}): + dev_reg.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=entry.entry_id, + ) + if device := dev_reg.async_get_device({(DOMAIN, "xbox_live")}): + dev_reg.async_update_device( + device.id, new_identifiers={(DOMAIN, client.xuid)} + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + hass.config_entries.async_schedule_reload(entry.entry_id) return True diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 1aa4b31241a..1728a58bd7c 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from typing import Any +from typing import TYPE_CHECKING, Any from pythonxbox.api.provider.people.models import Person from pythonxbox.api.provider.titlehub.models import Title @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import XboxConfigEntry @@ -112,30 +112,34 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" - xuids_added: set[str] = set() coordinator = entry.runtime_data.status - @callback - def add_entities() -> None: - nonlocal xuids_added - - current_xuids = set(coordinator.data.presence) - if new_xuids := current_xuids - xuids_added: - async_add_entities( - [ - XboxBinarySensorEntity(coordinator, xuid, description) - for xuid in new_xuids - for description in SENSOR_DESCRIPTIONS - if check_deprecated_entity( - hass, xuid, description, BINARY_SENSOR_DOMAIN - ) - ] + if TYPE_CHECKING: + assert entry.unique_id + async_add_entities( + [ + XboxBinarySensorEntity(coordinator, entry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if check_deprecated_entity( + hass, entry.unique_id, description, BINARY_SENSOR_DOMAIN ) - xuids_added |= new_xuids - xuids_added &= current_xuids + ] + ) - coordinator.async_add_listener(add_entities) - add_entities() + for subentry_id, subentry in entry.subentries.items(): + async_add_entities( + [ + XboxBinarySensorEntity(coordinator, subentry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if subentry.unique_id + and check_deprecated_entity( + hass, subentry.unique_id, description, BINARY_SENSOR_DOMAIN + ) + and subentry.unique_id in coordinator.data.presence + and subentry.subentry_type == "friend" + ], + config_subentry_id=subentry_id, + ) class XboxBinarySensorEntity(XboxBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/xbox/config_flow.py b/homeassistant/components/xbox/config_flow.py index 079e57a4a52..2650c8f67ca 100644 --- a/homeassistant/components/xbox/config_flow.py +++ b/homeassistant/components/xbox/config_flow.py @@ -8,11 +8,26 @@ from httpx import AsyncClient from pythonxbox.api.client import XboxLiveClient from pythonxbox.authentication.manager import AuthenticationManager from pythonxbox.authentication.models import OAuth2TokenResponse +import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigEntryState, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) -from .const import DOMAIN +from .const import CONF_XUID, DOMAIN +from .coordinator import XboxConfigEntry class OAuth2FlowHandler( @@ -22,7 +37,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN - MINOR_VERSION = 2 + MINOR_VERSION = 3 @property def logger(self) -> logging.Logger: @@ -35,6 +50,14 @@ class OAuth2FlowHandler( scopes = ["Xboxlive.signin", "Xboxlive.offline_access"] return {"scope": " ".join(scopes)} + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"friend": FriendSubentryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -65,6 +88,14 @@ class OAuth2FlowHandler( ) self._abort_if_unique_id_configured() + + config_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in config_entries: + if client.xuid in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured_as_subentry") + return self.async_create_entry(title=me.people[0].gamertag, data=data) async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: @@ -78,3 +109,63 @@ class OAuth2FlowHandler( if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + + +class FriendSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a friend.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + config_entry: XboxConfigEntry = self._get_entry() + if config_entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="config_entry_not_loaded") + + client = config_entry.runtime_data.status.client + friends_list = await client.people.get_friends_own() + + if user_input is not None: + config_entries = self.hass.config_entries.async_entries(DOMAIN) + if user_input[CONF_XUID] in {entry.unique_id for entry in config_entries}: + return self.async_abort(reason="already_configured_as_entry") + for entry in config_entries: + if user_input[CONF_XUID] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=next( + f.gamertag + for f in friends_list.people + if f.xuid == user_input[CONF_XUID] + ), + data={}, + unique_id=user_input[CONF_XUID], + ) + + if not friends_list.people: + return self.async_abort(reason="no_friends") + + options = [ + SelectOptionDict( + value=friend.xuid, + label=friend.gamertag, + ) + for friend in friends_list.people + ] + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_XUID): SelectSelector( + SelectSelectorConfig(options=options) + ) + } + ), + user_input, + ), + ) diff --git a/homeassistant/components/xbox/const.py b/homeassistant/components/xbox/const.py index 8879ef6d907..4ad1044b605 100644 --- a/homeassistant/components/xbox/const.py +++ b/homeassistant/components/xbox/const.py @@ -4,3 +4,5 @@ DOMAIN = "xbox" OAUTH2_AUTHORIZE = "https://login.live.com/oauth20_authorize.srf" OAUTH2_TOKEN = "https://login.live.com/oauth20_token.srf" + +CONF_XUID = "xuid" diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 7666fee3620..aadfd43b2cc 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -21,7 +21,6 @@ from pythonxbox.api.provider.titlehub.models import Title from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, @@ -81,10 +80,11 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=10), + update_interval=timedelta(seconds=15), ) self.data = XboxData() self.current_friends: set[str] = set() + self.title_data: dict[str, Title] = {} async def _async_setup(self) -> None: """Set up coordinator.""" @@ -207,17 +207,9 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): ) from e else: presence_data = {self.client.xuid: batch.people[0]} - configured_xuids = self.configured_as_entry() - presence_data.update( - { - friend.xuid: friend - for friend in friends.people - if friend.is_favorite and friend.xuid not in configured_xuids - } - ) + presence_data.update({friend.xuid: friend for friend in friends.people}) # retrieve title details - title_data: dict[str, Title] = {} for person in presence_data.values(): if presence_detail := next( ( @@ -227,6 +219,12 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): ), None, ): + if ( + person.xuid in self.title_data + and presence_detail.title_id + == self.title_data[person.xuid].title_id + ): + continue try: title = await self.client.titlehub.get_title_info( presence_detail.title_id @@ -250,16 +248,11 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): translation_domain=DOMAIN, translation_key="request_exception", ) from e - title_data[person.xuid] = title.titles[0] + self.title_data[person.xuid] = title.titles[0] + else: + self.title_data.pop(person.xuid, None) person.last_seen_date_time_utc = self.last_seen_timestamp(person) - if ( - self.current_friends - (new_friends := set(presence_data)) - or not self.current_friends - ): - self.remove_stale_devices(new_friends) - self.current_friends = new_friends - - return XboxData(new_console_data, presence_data, title_data) + return XboxData(new_console_data, presence_data, self.title_data) def last_seen_timestamp(self, person: Person) -> datetime | None: """Returns the most recent of two timestamps.""" @@ -277,25 +270,6 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): return cur_dt - def remove_stale_devices(self, xuids: set[str]) -> None: - """Remove stale devices from registry.""" - - device_reg = dr.async_get(self.hass) - identifiers = ( - {(DOMAIN, xuid) for xuid in xuids} - | {(DOMAIN, console.id) for console in self.consoles.result} - | self.configured_as_entry() - ) - - for device in dr.async_entries_for_config_entry( - device_reg, self.config_entry.entry_id - ): - if not set(device.identifiers) & identifiers: - _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) - def configured_as_entry(self) -> set[str]: """Get xuids of configured entries.""" diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index 67087fdb82d..ac406e2d64e 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -84,7 +84,8 @@ class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): return ( entity_picture - if (fn := self.entity_description.entity_picture_fn) is not None + if self.available + and (fn := self.entity_description.entity_picture_fn) is not None and (entity_picture := fn(self.data, self.title_info)) is not None else super().entity_picture ) @@ -98,6 +99,12 @@ class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): else super().extra_state_attributes ) + @property + def available(self) -> bool: + """Return True if entity is available.""" + + return super().available and self.xuid in self.coordinator.data.presence + class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): """Console base entity for the Xbox integration.""" diff --git a/homeassistant/components/xbox/image.py b/homeassistant/components/xbox/image.py index ac5d3d580a9..c6f10a2f625 100644 --- a/homeassistant/components/xbox/image.py +++ b/homeassistant/components/xbox/image.py @@ -5,12 +5,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum +from typing import TYPE_CHECKING from pythonxbox.api.provider.people.models import Person from pythonxbox.api.provider.titlehub.models import Title from homeassistant.components.image import ImageEntity, ImageEntityDescription -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -63,30 +64,27 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox images.""" - coordinator = config_entry.runtime_data.status + if TYPE_CHECKING: + assert config_entry.unique_id + async_add_entities( + [ + XboxImageEntity(hass, coordinator, config_entry.unique_id, description) + for description in IMAGE_DESCRIPTIONS + ] + ) - xuids_added: set[str] = set() - - @callback - def add_entities() -> None: - """Add image entities.""" - nonlocal xuids_added - - current_xuids = set(coordinator.data.presence) - if new_xuids := current_xuids - xuids_added: - async_add_entities( - [ - XboxImageEntity(hass, coordinator, xuid, description) - for xuid in new_xuids - for description in IMAGE_DESCRIPTIONS - ] - ) - xuids_added |= new_xuids - xuids_added &= current_xuids - - coordinator.async_add_listener(add_entities) - add_entities() + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [ + XboxImageEntity(hass, coordinator, subentry.unique_id, description) + for description in IMAGE_DESCRIPTIONS + if subentry.unique_id + and subentry.unique_id in coordinator.data.presence + and subentry.subentry_type == "friend" + ], + config_subentry_id=subentry_id, + ) class XboxImageEntity(XboxBaseEntity, ImageEntity): @@ -113,11 +111,12 @@ class XboxImageEntity(XboxBaseEntity, ImageEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - url = self.entity_description.image_url_fn(self.data, self.title_info) + if self.available: + url = self.entity_description.image_url_fn(self.data, self.title_info) - if url != self._attr_image_url: - self._attr_image_url = url - self._cached_image = None - self._attr_image_last_updated = dt_util.utcnow() + if url != self._attr_image_url: + self._attr_image_url = url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() super()._handle_coordinator_update() diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 9fadf325ce5..56775e1e266 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum -from typing import Any +from typing import TYPE_CHECKING, Any from pythonxbox.api.provider.people.models import Person from pythonxbox.api.provider.smartglass.models import SmartglassConsole, StorageDevice @@ -21,7 +21,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import CONF_NAME, UnitOfInformation -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -253,28 +253,32 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" - xuids_added: set[str] = set() coordinator = config_entry.runtime_data.status - - @callback - def add_entities() -> None: - nonlocal xuids_added - - current_xuids = set(coordinator.data.presence) - if new_xuids := current_xuids - xuids_added: - async_add_entities( - [ - XboxSensorEntity(coordinator, xuid, description) - for xuid in new_xuids - for description in SENSOR_DESCRIPTIONS - if check_deprecated_entity(hass, xuid, description, SENSOR_DOMAIN) - ] + if TYPE_CHECKING: + assert config_entry.unique_id + async_add_entities( + [ + XboxSensorEntity(coordinator, config_entry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if check_deprecated_entity( + hass, config_entry.unique_id, description, SENSOR_DOMAIN ) - xuids_added |= new_xuids - xuids_added &= current_xuids - - coordinator.async_add_listener(add_entities) - add_entities() + ] + ) + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [ + XboxSensorEntity(coordinator, subentry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if subentry.unique_id + and check_deprecated_entity( + hass, subentry.unique_id, description, SENSOR_DOMAIN + ) + and subentry.unique_id in coordinator.data.presence + and subentry.subentry_type == "friend" + ], + config_subentry_id=subentry_id, + ) consoles_coordinator = config_entry.runtime_data.consoles diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 155b580aa24..193b7bdfa53 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_configured_as_subentry": "This account is already configured as a sub-entry. Please remove the existing sub-entry before adding it.", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", @@ -34,6 +35,36 @@ } } }, + "config_subentries": { + "friend": { + "abort": { + "already_configured": "Already configured as a friend in this or another account.", + "already_configured_as_entry": "Already configured as a service. This account cannot be added as a friend.", + "config_entry_not_loaded": "Cannot add friend accounts when the main account is disabled or not loaded.", + "no_friends": "Looks like your friend list is empty right now. Add friends on Xbox Network first." + }, + "entry_type": "Friend", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add friend" + }, + "step": { + "user": { + "data": { + "xuid": "Gamertag" + }, + "data_description": { + "xuid": "Select a friend from your friend list to track their online status." + }, + "description": "Track the online status of an Xbox Network friend.", + "title": "Friend online status" + } + } + } + }, "entity": { "binary_sensor": { "has_game_pass": { diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index e09e176e46e..6968bd92143 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.1"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e78de0d92f7..41e1c8ae914 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -22,7 +22,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.80"], + "requirements": ["zha==0.0.81"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index d106ea092a8..ee3f286c660 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any, Unpack, cast import voluptuous as vol @@ -22,11 +22,11 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionCheckerType, + ConditionChecker, + ConditionCheckParams, ConditionConfig, - trace_condition_function, ) -from homeassistant.helpers.typing import ConfigType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType from . import in_zone @@ -118,13 +118,12 @@ class ZoneCondition(Condition): assert config.options is not None self._options = config.options - async def async_get_checker(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Wrap action method with zone based condition.""" entity_ids = self._options.get(CONF_ENTITY_ID, []) zone_entity_ids = self._options.get(CONF_ZONE, []) - @trace_condition_function - def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + def if_in_zone(**kwargs: Unpack[ConditionCheckParams]) -> bool: """Test if condition.""" errors = [] @@ -133,7 +132,7 @@ class ZoneCondition(Condition): entity_ok = False for zone_entity_id in zone_entity_ids: try: - if zone(hass, zone_entity_id, entity_id): + if zone(self._hass, zone_entity_id, entity_id): entity_ok = True except ConditionErrorMessage as ex: errors.append( diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 877841ef2ff..35be1a0229c 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -214,6 +214,7 @@ DISCOVERY_SCHEMAS = [ 0x3131, 0x3337, # 14287 / 55258 / ZW4002 0x3533, # 58446 / ZWA4013 + 0x3138, # 14314 / ZW4002 }, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, @@ -221,14 +222,6 @@ DISCOVERY_SCHEMAS = [ FanValueMapping(speeds=[(1, 32), (33, 66), (67, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Control - 14314 / ZW4002 - ZWaveDiscoverySchema( - platform=Platform.FAN, - manufacturer_id={0x0063}, - product_id={0x3138}, - product_type={0x4944}, - primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, - ), # Leviton ZW4SF fan controllers using switch multilevel CC ZWaveDiscoverySchema( platform=Platform.FAN, diff --git a/homeassistant/core.py b/homeassistant/core.py index 584616277d4..917d86d752c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -84,12 +84,6 @@ from .exceptions import ( ServiceValidationError, Unauthorized, ) -from .helpers.deprecation import ( - DeferredDeprecatedAlias, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from .helpers.json import json_bytes, json_fragment from .helpers.typing import VolSchemaType from .util import dt as dt_util @@ -161,18 +155,6 @@ class EventStateReportedData(EventStateEventData): old_last_reported: datetime.datetime -def _deprecated_core_config() -> Any: - from . import core_config # noqa: PLC0415 - - return core_config.Config - - -# The Config class was moved to core_config in Home Assistant 2024.11 -_DEPRECATED_Config = DeferredDeprecatedAlias( - _deprecated_core_config, "homeassistant.core_config.Config", "2025.11" -) - - # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -280,6 +262,8 @@ def async_get_hass_or_none() -> HomeAssistant | None: class ReleaseChannel(enum.StrEnum): + """Release channel.""" + BETA = "beta" DEV = "dev" NIGHTLY = "nightly" @@ -2883,11 +2867,3 @@ class ServiceRegistry: if TYPE_CHECKING: target = cast(Callable[..., ServiceResponse], target) return await self._hass.async_add_executor_job(target, service_call) - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = functools.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = functools.partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 1f9d3e005a6..0b0663d2183 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -40,6 +40,7 @@ APPLICATION_CREDENTIALS = [ "tesla_fleet", "twitch", "volvo", + "watts", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a3688a97bb7..0aa3b8869e3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -40,6 +40,7 @@ FLOWS = { "airnow", "airobot", "airos", + "airpatrol", "airq", "airthings", "airthings_ble", @@ -277,6 +278,7 @@ FLOWS = { "harmony", "heos", "here_travel_time", + "hikvision", "hisense_aehw4a1", "hive", "hko", @@ -749,6 +751,7 @@ FLOWS = { "wallbox", "waqi", "watergate", + "watts", "watttime", "waze_travel_time", "weatherflow", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index d315640bc18..bce067a9fd6 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -829,6 +829,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "my[45]50*", "macaddress": "001E0C*", }, + { + "domain": "sunricher_dali", + "registered_devices": True, + }, { "domain": "tado", "hostname": "tado*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a1c226c8467..3ea809b08f1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -79,7 +79,7 @@ }, "aemet": { "name": "AEMET OpenData", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -95,7 +95,7 @@ }, "aftership": { "name": "AfterShip", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -119,7 +119,7 @@ }, "airnow": { "name": "AirNow", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -129,6 +129,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "airpatrol": { + "name": "AirPatrol", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "airq": { "name": "air-Q", "integration_type": "hub", @@ -145,7 +151,7 @@ "name": "Airthings" }, "airthings_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Airthings BLE" @@ -154,7 +160,7 @@ }, "airtouch4": { "name": "AirTouch 4", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -270,7 +276,7 @@ }, "amberelectric": { "name": "Amber Electric", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -317,7 +323,7 @@ }, "android_ip_webcam": { "name": "Android IP Webcam", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -341,7 +347,7 @@ }, "anglian_water": { "name": "Anglian Water", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -353,7 +359,7 @@ }, "anthemav": { "name": "Anthem A/V Receivers", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -382,7 +388,7 @@ }, "apcupsd": { "name": "APC UPS Daemon", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -499,7 +505,7 @@ }, "arcam_fmj": { "name": "Arcam FMJ Receivers", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -563,7 +569,7 @@ }, "atag": { "name": "Atag", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -602,7 +608,7 @@ } }, "aurora": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -614,7 +620,7 @@ }, "aussie_broadband": { "name": "Aussie Broadband", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -657,7 +663,7 @@ }, "baf": { "name": "Big Ass Fans", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -674,7 +680,7 @@ }, "balboa": { "name": "Balboa Spa Client", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -732,7 +738,7 @@ }, "blebox": { "name": "BleBox devices", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -772,13 +778,13 @@ }, "bluemaestro": { "name": "BlueMaestro", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, "bluesound": { "name": "Bluesound", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -859,7 +865,7 @@ }, "brottsplatskartan": { "name": "Brottsplatskartan", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -922,7 +928,7 @@ }, "buienradar": { "name": "Buienradar", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -933,7 +939,7 @@ }, "caldav": { "name": "CalDAV", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -957,7 +963,7 @@ "iot_class": "local_polling" }, "cert_expiry": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1033,7 +1039,7 @@ }, "cloudflare": { "name": "Cloudflare", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "single_config_entry": true @@ -1057,7 +1063,7 @@ }, "coinbase": { "name": "Coinbase", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1188,7 +1194,7 @@ }, "daikin": { "name": "Daikin AC", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1200,7 +1206,7 @@ }, "datadog": { "name": "Datadog", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, @@ -1273,7 +1279,7 @@ "name": "Denon Network Receivers" }, "denonavr": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Denon AVR Network Receivers" @@ -1320,7 +1326,7 @@ }, "dexcom": { "name": "Dexcom", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1389,7 +1395,7 @@ }, "dnsip": { "name": "DNS IP", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1401,7 +1407,7 @@ }, "doorbird": { "name": "DoorBird", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -1442,7 +1448,7 @@ }, "droplet": { "name": "Droplet", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -1472,13 +1478,13 @@ }, "duke_energy": { "name": "Duke Energy", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, "dunehd": { "name": "Dune HD", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1502,7 +1508,7 @@ }, "eafm": { "name": "Environment Agency Flood Gauges", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1545,7 +1551,7 @@ }, "ecoforest": { "name": "Ecoforest", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1676,7 +1682,7 @@ }, "elvia": { "name": "Elvia", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1690,7 +1696,7 @@ "name": "emoncms", "integrations": { "emoncms": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling", "name": "Emoncms" @@ -1705,7 +1711,7 @@ }, "emonitor": { "name": "SiteSage Emonitor", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1757,7 +1763,7 @@ }, "enocean": { "name": "EnOcean", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "single_config_entry": true @@ -1770,13 +1776,13 @@ }, "entur_public_transport": { "name": "Entur", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "cloud_polling" }, "environment_canada": { "name": "Environment Canada", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1806,7 +1812,7 @@ }, "epson": { "name": "Epson", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1829,7 +1835,7 @@ }, "escea": { "name": "Escea", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -1893,7 +1899,7 @@ }, "evil_genius_labs": { "name": "Evil Genius Labs", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1905,7 +1911,7 @@ }, "faa_delays": { "name": "FAA Delays", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1976,7 +1982,7 @@ }, "fing": { "name": "Fing", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -1988,13 +1994,13 @@ }, "firefly_iii": { "name": "Firefly III", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, "fireservicerota": { "name": "FireServiceRota", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2006,13 +2012,13 @@ }, "fitbit": { "name": "Fitbit", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, "fivem": { "name": "FiveM", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -2136,7 +2142,7 @@ }, "foscam": { "name": "Foscam", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2159,7 +2165,7 @@ }, "freebox": { "name": "Freebox", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2218,7 +2224,7 @@ }, "frontier_silicon": { "name": "Frontier Silicon", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2241,7 +2247,7 @@ }, "fully_kiosk": { "name": "Fully Kiosk Browser", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2269,13 +2275,13 @@ "iot_class": "cloud_polling" }, "garages_amsterdam": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, "gardena_bluetooth": { "name": "Gardena Bluetooth", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2321,7 +2327,7 @@ }, "geocaching": { "name": "Geocaching", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2356,7 +2362,7 @@ }, "github": { "name": "GitHub", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2374,7 +2380,7 @@ }, "glances": { "name": "Glances", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -2409,7 +2415,7 @@ }, "goodwe": { "name": "GoodWe Inverter", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2459,7 +2465,7 @@ "name": "Google Maps" }, "google_photos": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Photos" @@ -2477,7 +2483,7 @@ "name": "Google Sheets" }, "google_tasks": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Tasks" @@ -2489,7 +2495,7 @@ "name": "Google Translate text-to-speech" }, "google_travel_time": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2541,7 +2547,7 @@ "name": "Govee", "integrations": { "govee_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Govee Bluetooth" @@ -2690,7 +2696,7 @@ }, "here_travel_time": { "name": "HERE Travel Time", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2708,8 +2714,8 @@ "name": "Hikvision", "integrations": { "hikvision": { - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_push", "name": "Hikvision" }, @@ -2741,13 +2747,13 @@ }, "hko": { "name": "Hong Kong Observatory", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, "hlk_sw16": { "name": "Hi-Link HLK-SW16", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -2813,7 +2819,7 @@ ] }, "homewizard": { - "name": "HomeWizard Energy", + "name": "HomeWizard", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" @@ -2862,13 +2868,13 @@ }, "huawei_lte": { "name": "Huawei LTE", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, "huisbaasje": { "name": "EnergyFlip", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2893,7 +2899,7 @@ "name": "Husqvarna Automower" }, "husqvarna_automower_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Husqvarna Automower BLE" @@ -2902,13 +2908,13 @@ }, "huum": { "name": "Huum", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, "hvv_departures": { "name": "HVV Departures", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2926,7 +2932,7 @@ }, "ialarm": { "name": "Antifurto365 iAlarm", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3004,7 +3010,7 @@ }, "imap": { "name": "IMAP", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push" }, @@ -3016,7 +3022,7 @@ }, "imgw_pib": { "name": "IMGW-PIB", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3058,7 +3064,7 @@ }, "inkbird": { "name": "INKBIRD", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3083,7 +3089,7 @@ }, "intellifire": { "name": "IntelliFire", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3112,7 +3118,7 @@ }, "iotawatt": { "name": "IoTaWatt", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3130,7 +3136,7 @@ }, "ipma": { "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3170,7 +3176,7 @@ "iot_class": "local_polling" }, "islamic_prayer_times": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "calculated" }, @@ -3181,7 +3187,7 @@ }, "israel_rail": { "name": "Israel Railways", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3194,7 +3200,7 @@ }, "ista_ecotrend": { "name": "ista EcoTrend", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3243,7 +3249,7 @@ }, "justnimbus": { "name": "JustNimbus", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3266,7 +3272,7 @@ }, "kaleidescape": { "name": "Kaleidescape", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3284,7 +3290,7 @@ }, "keenetic_ndms2": { "name": "Keenetic NDMS2 Router", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3296,7 +3302,7 @@ }, "kegtron": { "name": "Kegtron", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3338,7 +3344,7 @@ }, "kmtronic": { "name": "KMtronic", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3357,7 +3363,7 @@ }, "kodi": { "name": "Kodi", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, @@ -3380,13 +3386,13 @@ }, "kostal_plenticore": { "name": "Kostal Plenticore Solar Inverter", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, "kraken": { "name": "Kraken", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3397,7 +3403,7 @@ }, "kulersky": { "name": "Kuler Sky", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3433,7 +3439,7 @@ }, "landisgyr_heat_meter": { "name": "Landis+Gyr Heat Meter", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3445,7 +3451,7 @@ }, "lastfm": { "name": "Last.fm", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3476,13 +3482,13 @@ }, "leaone": { "name": "LeaOne", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, "led_ble": { "name": "LED BLE", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3530,7 +3536,7 @@ "name": "LG Netcast" }, "lg_soundbar": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "LG Soundbars" @@ -3551,7 +3557,7 @@ }, "libre_hardware_monitor": { "name": "Libre Hardware Monitor", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3563,7 +3569,7 @@ }, "lifx": { "name": "LIFX", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3687,7 +3693,7 @@ "name": "Logitech", "integrations": { "harmony": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Logitech Harmony Hub" @@ -3721,7 +3727,7 @@ }, "loqed": { "name": "LOQED Touch Smart Lock", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3790,7 +3796,7 @@ }, "mailgun": { "name": "Mailgun", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push" }, @@ -3860,7 +3866,7 @@ }, "medcom_ble": { "name": "Medcom Bluetooth", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3893,7 +3899,7 @@ "name": "Melnor", "integrations": { "melnor": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Melnor Bluetooth" @@ -3926,13 +3932,13 @@ }, "met_eireann": { "name": "Met \u00c9ireann", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, "meteo_france": { "name": "M\u00e9t\u00e9o-France", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3976,19 +3982,19 @@ "name": "Microsoft", "integrations": { "azure_data_explorer": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "name": "Azure Data Explorer" }, "azure_devops": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Azure DevOps" }, "azure_event_hub": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "name": "Azure Event Hub" @@ -6472,7 +6478,7 @@ "iot_class": "cloud_polling" }, "sun": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "calculated", "single_config_entry": true @@ -7466,6 +7472,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "watts": { + "name": "Watts Vision +", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "watttime": { "name": "WattTime", "integration_type": "service", @@ -7584,7 +7596,7 @@ "iot_class": "cloud_polling" }, "workday": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index b99079822d8..957ff25434f 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -13,7 +13,7 @@ import inspect import logging import re import sys -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, TypedDict, Unpack, cast, overload import voluptuous as vol @@ -298,7 +298,7 @@ class Condition(abc.ABC): self._hass = hass @abc.abstractmethod - async def async_get_checker(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Get the condition checker.""" @@ -319,7 +319,23 @@ class ConditionConfig: target: dict[str, Any] | None = None -type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] +class ConditionCheckParams(TypedDict, total=False): + """Condition check params.""" + + variables: TemplateVarsType + + +class ConditionChecker(Protocol): + """Protocol for condition checker callable with typed kwargs.""" + + def __call__(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Check the condition.""" + + +type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool] +type ConditionCheckerTypeOptional = Callable[ + [HomeAssistant, TemplateVarsType], bool | None +] def condition_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: @@ -374,7 +390,21 @@ def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement]: trace_stack_pop(trace_stack_cv) -def trace_condition_function(condition: ConditionCheckerType) -> ConditionCheckerType: +@overload +def trace_condition_function( + condition: ConditionCheckerType, +) -> ConditionCheckerType: ... + + +@overload +def trace_condition_function( + condition: ConditionCheckerTypeOptional, +) -> ConditionCheckerTypeOptional: ... + + +def trace_condition_function( + condition: ConditionCheckerType | ConditionCheckerTypeOptional, +) -> ConditionCheckerType | ConditionCheckerTypeOptional: """Wrap a condition function to enable basic tracing.""" @ft.wraps(condition) @@ -420,10 +450,20 @@ async def _async_get_condition_platform( ) from None +async def _async_get_checker(condition: Condition) -> ConditionCheckerType: + new_checker = await condition.async_get_checker() + + @trace_condition_function + def checker(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + return new_checker(variables=variables) + + return checker + + async def async_from_config( hass: HomeAssistant, config: ConfigType, -) -> ConditionCheckerType: +) -> ConditionCheckerTypeOptional: """Turn a condition configuration into a method. Should be run on the event loop. @@ -466,7 +506,7 @@ async def async_from_config( target=config.get(CONF_TARGET), ), ) - return await condition.async_get_checker() + return await _async_get_checker(condition) for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) @@ -1131,7 +1171,7 @@ async def async_conditions_from_config( name: str, ) -> Callable[[TemplateVarsType], bool]: """AND all conditions.""" - checks: list[ConditionCheckerType] = [ + checks = [ await async_from_config(hass, condition_config) for condition_config in condition_configs ] @@ -1330,7 +1370,6 @@ async def async_get_all_descriptions( continue description = {"fields": yaml_description.get("fields", {})} - if (target := yaml_description.get("target")) is not None: description["target"] = target diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 937968e9742..3d7b99d571c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -86,7 +86,7 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.signal_type import SignalType, SignalTypeFormat from . import condition, config_validation as cv, service, template -from .condition import ConditionCheckerType, trace_condition_function +from .condition import ConditionCheckerTypeOptional, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template from .script_variables import ScriptRunVariables, ScriptVariables @@ -675,12 +675,14 @@ class _ScriptRun: ### Condition actions ### - async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType: + async def _async_get_condition( + self, config: ConfigType + ) -> ConditionCheckerTypeOptional: return await self._script._async_get_condition(config) # noqa: SLF001 def _test_conditions( self, - conditions: list[ConditionCheckerType], + conditions: list[ConditionCheckerTypeOptional], name: str, condition_path: str | None = None, ) -> bool | None: @@ -1404,12 +1406,12 @@ def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: class _ChooseData(TypedDict): - choices: list[tuple[list[ConditionCheckerType], Script]] + choices: list[tuple[list[ConditionCheckerTypeOptional], Script]] default: Script | None class _IfData(TypedDict): - if_conditions: list[ConditionCheckerType] + if_conditions: list[ConditionCheckerTypeOptional] if_then: Script if_else: Script | None @@ -1486,7 +1488,9 @@ class Script: self._max_exceeded = max_exceeded if script_mode == SCRIPT_MODE_QUEUED: self._queue_lck = asyncio.Lock() - self._config_cache: dict[frozenset[tuple[str, str]], ConditionCheckerType] = {} + self._config_cache: dict[ + frozenset[tuple[str, str]], ConditionCheckerTypeOptional + ] = {} self._repeat_script: dict[int, Script] = {} self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} @@ -1857,7 +1861,9 @@ class Script: return await asyncio.shield(create_eager_task(self._async_stop(aws, update_state))) - async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType: + async def _async_get_condition( + self, config: ConfigType + ) -> ConditionCheckerTypeOptional: config_cache_key = frozenset((k, str(v)) for k, v in config.items()) if not (cond := self._config_cache.get(config_cache_key)): cond = await condition.async_from_config(self._hass, config) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 8b28df0f19a..f759d4ae61f 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -223,10 +223,10 @@ class ServiceParams(TypedDict): @deprecated_class( - "homeassistant.helpers.target.TargetSelectorData", + "homeassistant.helpers.target.TargetSelection", breaks_in_ha_version="2026.8", ) -class ServiceTargetSelector(target_helpers.TargetSelectorData): +class ServiceTargetSelector(target_helpers.TargetSelection): """Class to hold a target selector for a service.""" def __init__(self, service_call: ServiceCall) -> None: @@ -406,9 +406,9 @@ async def async_extract_entities[_EntityT: Entity]( if data_ent_id == ENTITY_MATCH_ALL: return [entity for entity in entities if entity.available] - selector_data = target_helpers.TargetSelectorData(service_call.data) + target_selection = target_helpers.TargetSelection(service_call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - service_call.hass, selector_data, expand_group + service_call.hass, target_selection, expand_group ) combined = referenced.referenced | referenced.indirectly_referenced @@ -438,9 +438,9 @@ async def async_extract_entity_ids( Will convert group entity ids to the entity ids it represents. """ - selector_data = target_helpers.TargetSelectorData(service_call.data) + target_selection = target_helpers.TargetSelection(service_call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - service_call.hass, selector_data, expand_group + service_call.hass, target_selection, expand_group ) return referenced.referenced | referenced.indirectly_referenced @@ -454,9 +454,9 @@ def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" - selector_data = target_helpers.TargetSelectorData(service_call.data) + target_selection = target_helpers.TargetSelection(service_call.data) selected = target_helpers.async_extract_referenced_entity_ids( - hass, selector_data, expand_group + hass, target_selection, expand_group ) return SelectedEntities(**dataclasses.asdict(selected)) @@ -466,9 +466,9 @@ async def async_extract_config_entry_ids( service_call: ServiceCall, expand_group: bool = True ) -> set[str]: """Extract referenced config entry ids from a service call.""" - selector_data = target_helpers.TargetSelectorData(service_call.data) + target_selection = target_helpers.TargetSelection(service_call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - service_call.hass, selector_data, expand_group + service_call.hass, target_selection, expand_group ) ent_reg = entity_registry.async_get(service_call.hass) dev_reg = device_registry.async_get(service_call.hass) @@ -752,9 +752,9 @@ async def entity_service_call( all_referenced: set[str] | None = None else: # A set of entities we're trying to target. - selector_data = target_helpers.TargetSelectorData(call.data) + target_selection = target_helpers.TargetSelection(call.data) referenced = target_helpers.async_extract_referenced_entity_ids( - hass, selector_data, True + hass, target_selection, True ) all_referenced = referenced.referenced | referenced.indirectly_referenced diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 81edd3eff3e..b65ed720a82 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -34,6 +34,7 @@ from . import ( group, label_registry as lr, ) +from .deprecation import deprecated_class from .event import async_track_state_change_event from .typing import ConfigType @@ -53,8 +54,8 @@ def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: return ids not in (None, ENTITY_MATCH_NONE) -class TargetSelectorData: - """Class to hold data of target selector.""" +class TargetSelection: + """Class to represent target selection.""" __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") @@ -81,8 +82,8 @@ class TargetSelectorData: ) @property - def has_any_selector(self) -> bool: - """Determine if any selectors are present.""" + def has_any_target(self) -> bool: + """Determine if any target is present.""" return bool( self.entity_ids or self.device_ids @@ -92,6 +93,16 @@ class TargetSelectorData: ) +@deprecated_class("TargetSelection", breaks_in_ha_version="2026.12.0") +class TargetSelectorData(TargetSelection): + """Class to represent target selector data.""" + + @property + def has_any_selector(self) -> bool: + """Determine if any selectors are present.""" + return super().has_any_target + + @dataclasses.dataclass(slots=True) class SelectedEntities: """Class to hold the selected entities.""" @@ -135,25 +146,25 @@ class SelectedEntities: def async_extract_referenced_entity_ids( - hass: HomeAssistant, selector_data: TargetSelectorData, expand_group: bool = True + hass: HomeAssistant, target_selection: TargetSelection, expand_group: bool = True ) -> SelectedEntities: - """Extract referenced entity IDs from a target selector.""" + """Extract referenced entity IDs from a target selection.""" selected = SelectedEntities() - if not selector_data.has_any_selector: + if not target_selection.has_any_target: return selected - entity_ids: set[str] | list[str] = selector_data.entity_ids + entity_ids: set[str] | list[str] = target_selection.entity_ids if expand_group: entity_ids = group.expand_entity_ids(hass, entity_ids) selected.referenced.update(entity_ids) if ( - not selector_data.device_ids - and not selector_data.area_ids - and not selector_data.floor_ids - and not selector_data.label_ids + not target_selection.device_ids + and not target_selection.area_ids + and not target_selection.floor_ids + and not target_selection.label_ids ): return selected @@ -161,23 +172,23 @@ def async_extract_referenced_entity_ids( dev_reg = dr.async_get(hass) area_reg = ar.async_get(hass) - if selector_data.floor_ids: + if target_selection.floor_ids: floor_reg = fr.async_get(hass) - for floor_id in selector_data.floor_ids: + for floor_id in target_selection.floor_ids: if floor_id not in floor_reg.floors: selected.missing_floors.add(floor_id) - for area_id in selector_data.area_ids: + for area_id in target_selection.area_ids: if area_id not in area_reg.areas: selected.missing_areas.add(area_id) - for device_id in selector_data.device_ids: + for device_id in target_selection.device_ids: if device_id not in dev_reg.devices: selected.missing_devices.add(device_id) - if selector_data.label_ids: + if target_selection.label_ids: label_reg = lr.async_get(hass) - for label_id in selector_data.label_ids: + for label_id in target_selection.label_ids: if label_id not in label_reg.labels: selected.missing_labels.add(label_id) @@ -192,15 +203,15 @@ def async_extract_referenced_entity_ids( selected.referenced_areas.add(area_entry.id) # Find areas for targeted floors - if selector_data.floor_ids: + if target_selection.floor_ids: selected.referenced_areas.update( area_entry.id - for floor_id in selector_data.floor_ids + for floor_id in target_selection.floor_ids for area_entry in area_reg.areas.get_areas_for_floor(floor_id) ) - selected.referenced_areas.update(selector_data.area_ids) - selected.referenced_devices.update(selector_data.device_ids) + selected.referenced_areas.update(target_selection.area_ids) + selected.referenced_devices.update(target_selection.device_ids) if not selected.referenced_areas and not selected.referenced_devices: return selected @@ -263,13 +274,13 @@ class TargetStateChangeTracker: def __init__( self, hass: HomeAssistant, - selector_data: TargetSelectorData, + target_selection: TargetSelection, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]], ) -> None: """Initialize the state change tracker.""" self._hass = hass - self._selector_data = selector_data + self._target_selection = target_selection self._action = action self._entity_filter = entity_filter @@ -285,7 +296,7 @@ class TargetStateChangeTracker: def _track_entities_state_change(self) -> None: """Set up state change tracking for currently selected entities.""" selected = async_extract_referenced_entity_ids( - self._hass, self._selector_data, expand_group=False + self._hass, self._target_selection, expand_group=False ) tracked_entities = self._entity_filter( @@ -352,10 +363,10 @@ def async_track_target_selector_state_change_event( entity_filter: Callable[[set[str]], set[str]] = lambda x: x, ) -> CALLBACK_TYPE: """Track state changes for entities referenced directly or indirectly in a target selector.""" - selector_data = TargetSelectorData(target_selector_config) - if not selector_data.has_any_selector: + target_selection = TargetSelection(target_selector_config) + if not target_selection.has_any_target: raise HomeAssistantError( f"Target selector {target_selector_config} does not have any selectors defined" ) - tracker = TargetStateChangeTracker(hass, selector_data, action, entity_filter) + tracker = TargetStateChangeTracker(hass, target_selection, action, entity_filter) return tracker.async_setup() diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 033ebd93a29..bd0d55a7a8f 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -337,18 +337,21 @@ class EntityTriggerBase(Trigger): self._options = config.options or {} self._target = config.target - def is_from_state(self, from_state: State, to_state: State) -> bool: - """Check if the state matches the origin state.""" - return not self.is_to_state(from_state) + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is valid and the state has changed.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + + return from_state.state != to_state.state @abc.abstractmethod - def is_to_state(self, state: State) -> bool: - """Check if the state matches the target state.""" + def is_valid_state(self, state: State) -> bool: + """Check if the new state matches the expected state(s).""" def check_all_match(self, entity_ids: set[str]) -> bool: """Check if all entity states match.""" return all( - self.is_to_state(state) + self.is_valid_state(state) for entity_id in entity_ids if (state := self._hass.states.get(entity_id)) is not None ) @@ -357,7 +360,7 @@ class EntityTriggerBase(Trigger): """Check that only one entity state matches.""" return ( sum( - self.is_to_state(state) + self.is_valid_state(state) for entity_id in entity_ids if (state := self._hass.states.get(entity_id)) is not None ) @@ -390,16 +393,15 @@ class EntityTriggerBase(Trigger): from_state = event.data["old_state"] to_state = event.data["new_state"] - # The trigger should never fire if the previous state was not a valid state - if not from_state or from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + if not from_state or not to_state: return - # The trigger should never fire if the previous state was not the from state - if not to_state or not self.is_from_state(from_state, to_state): + # The trigger should never fire if the new state is not valid + if not self.is_valid_state(to_state): return - # The trigger should never fire if the new state is not the to state - if not self.is_to_state(to_state): + # The trigger should never fire if the transition is not valid + if not self.is_valid_transition(from_state, to_state): return if behavior == BEHAVIOR_LAST: @@ -428,48 +430,76 @@ class EntityTriggerBase(Trigger): ) -class EntityStateTriggerBase(EntityTriggerBase): - """Trigger for entity state changes.""" +class EntityTargetStateTriggerBase(EntityTriggerBase): + """Trigger for entity state changes to a specific state.""" _to_state: str - def is_to_state(self, state: State) -> bool: - """Check if the state matches the target state.""" + def is_valid_state(self, state: State) -> bool: + """Check if the new state matches the expected state.""" return state.state == self._to_state -class ConditionalEntityStateTriggerBase(EntityTriggerBase): - """Class for entity state changes where the from state is restricted.""" +class EntityTransitionTriggerBase(EntityTriggerBase): + """Trigger for entity state changes between specific states.""" _from_states: set[str] _to_states: set[str] - def is_from_state(self, from_state: State, to_state: State) -> bool: - """Check if the state matches the origin state.""" + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state matches the expected ones.""" + if not super().is_valid_transition(from_state, to_state): + return False + return from_state.state in self._from_states - def is_to_state(self, state: State) -> bool: - """Check if the state matches the target state.""" + def is_valid_state(self, state: State) -> bool: + """Check if the new state matches the expected states.""" return state.state in self._to_states -class EntityStateAttributeTriggerBase(EntityTriggerBase): - """Trigger for entity state attribute changes.""" +class EntityOriginStateTriggerBase(EntityTriggerBase): + """Trigger for entity state changes from a specific state.""" + + _from_state: str + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state matches the expected one and that the state changed.""" + return ( + from_state.state == self._from_state and to_state.state != self._from_state + ) + + def is_valid_state(self, state: State) -> bool: + """Check if the new state is not the same as the expected origin state.""" + return state.state != self._from_state + + +class EntityTargetStateAttributeTriggerBase(EntityTriggerBase): + """Trigger for entity state attribute changes to a specific state.""" _attribute: str _attribute_to_state: str - def is_to_state(self, state: State) -> bool: - """Check if the state matches the target state.""" + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is valid and the state has changed.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + + return from_state.attributes.get(self._attribute) != to_state.attributes.get( + self._attribute + ) + + def is_valid_state(self, state: State) -> bool: + """Check if the new state attribute matches the expected one.""" return state.attributes.get(self._attribute) == self._attribute_to_state -def make_entity_state_trigger( +def make_entity_target_state_trigger( domain: str, to_state: str -) -> type[EntityStateTriggerBase]: - """Create an entity state trigger class.""" +) -> type[EntityTargetStateTriggerBase]: + """Create a trigger for entity state changes to a specific state.""" - class CustomTrigger(EntityStateTriggerBase): + class CustomTrigger(EntityTargetStateTriggerBase): """Trigger for entity state changes.""" _domain = domain @@ -478,12 +508,12 @@ def make_entity_state_trigger( return CustomTrigger -def make_conditional_entity_state_trigger( +def make_entity_transition_trigger( domain: str, *, from_states: set[str], to_states: set[str] -) -> type[ConditionalEntityStateTriggerBase]: - """Create a conditional entity state trigger class.""" +) -> type[EntityTransitionTriggerBase]: + """Create a trigger for entity state changes between specific states.""" - class CustomTrigger(ConditionalEntityStateTriggerBase): + class CustomTrigger(EntityTransitionTriggerBase): """Trigger for conditional entity state changes.""" _domain = domain @@ -493,12 +523,26 @@ def make_conditional_entity_state_trigger( return CustomTrigger -def make_entity_state_attribute_trigger( - domain: str, attribute: str, to_state: str -) -> type[EntityStateAttributeTriggerBase]: - """Create an entity state attribute trigger class.""" +def make_entity_origin_state_trigger( + domain: str, *, from_state: str +) -> type[EntityOriginStateTriggerBase]: + """Create a trigger for entity state changes from a specific state.""" - class CustomTrigger(EntityStateAttributeTriggerBase): + class CustomTrigger(EntityOriginStateTriggerBase): + """Trigger for entity "from state" changes.""" + + _domain = domain + _from_state = from_state + + return CustomTrigger + + +def make_entity_target_state_attribute_trigger( + domain: str, attribute: str, to_state: str +) -> type[EntityTargetStateAttributeTriggerBase]: + """Create a trigger for entity state attribute changes to a specific state.""" + + class CustomTrigger(EntityTargetStateAttributeTriggerBase): """Trigger for entity state changes.""" _domain = domain @@ -1091,6 +1135,5 @@ async def async_get_all_descriptions( description["target"] = target new_descriptions_cache[missing_trigger] = description - hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache return new_descriptions_cache diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index dde456bf7bc..9c044c7ea03 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -2,18 +2,10 @@ from collections.abc import Mapping from enum import Enum -from functools import partial from typing import Any, Never import voluptuous as vol -from .deprecation import ( - DeferredDeprecatedAlias, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) - type GPSType = tuple[float, float] type ConfigType = dict[str, Any] type DiscoveryInfoType = dict[str, Any] @@ -35,32 +27,3 @@ class UndefinedType(Enum): UNDEFINED = UndefinedType._singleton # noqa: SLF001 - - -def _deprecated_typing_helper(attr: str) -> DeferredDeprecatedAlias: - """Help to make a DeferredDeprecatedAlias.""" - - def value_fn() -> Any: - import homeassistant.core # noqa: PLC0415 - - return getattr(homeassistant.core, attr) - - return DeferredDeprecatedAlias(value_fn, f"homeassistant.core.{attr}", "2025.5") - - -# The following types should not used and -# are not present in the core code base. -# They are kept in order not to break custom integrations -# that may rely on them. -# Deprecated as of 2024.5 use types from homeassistant.core instead. -_DEPRECATED_ContextType = _deprecated_typing_helper("Context") -_DEPRECATED_EventType = _deprecated_typing_helper("Event") -_DEPRECATED_HomeAssistantType = _deprecated_typing_helper("HomeAssistant") -_DEPRECATED_ServiceCallType = _deprecated_typing_helper("ServiceCall") - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 126decbd289..84de85158e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 -aiodns==3.6.0 +aiodns==3.6.1 aiohasupervisor==0.3.3 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.46.0 +async-upnp-client==0.46.1 atomicwrites-homeassistant==1.4.1 attrs==25.4.0 audioop-lts==0.2.1 @@ -56,7 +56,7 @@ PyJWT==2.10.1 PyNaCl==1.6.0 pyOpenSSL==25.3.0 pyserial==3.5 -pysilero-vad==3.0.0 +pysilero-vad==3.0.1 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 @@ -69,7 +69,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 -uv==0.9.6 +uv==0.9.17 voluptuous-openapi==0.1.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 89d06b3132d..a31e58c5743 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -227,7 +227,7 @@ def _async_loop_exception_handler( if source_traceback := context.get("source_traceback"): stack_summary = "".join(traceback.format_list(source_traceback)) logger.error( - "Error doing job: %s (%s): %s", + "Error doing job: %s (task: %s): %s", context["message"], context.get("task"), stack_summary, @@ -236,7 +236,7 @@ def _async_loop_exception_handler( return logger.error( - "Error doing job: %s (%s)", + "Error doing job: %s (task: %s)", context["message"], context.get("task"), **kwargs, # type: ignore[arg-type] diff --git a/mypy.ini b/mypy.ini index 93cd23c31a7..e21f8fd44c3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5429,6 +5429,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.watts.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.watttime.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index cd736823012..69d7787cd1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] requires-python = ">=3.13.2" dependencies = [ - "aiodns==3.6.0", + "aiodns==3.6.1", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 @@ -75,7 +75,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==1.5.2", "urllib3>=2.0", - "uv==0.9.6", + "uv==0.9.17", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.1.0", @@ -492,15 +492,15 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/ReactiveX/RxPY/pull/716 - >4.0.4 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:reactivex.internal.constants", + # https://github.com/BerriAI/litellm/pull/17657 - >1.80.9 + "ignore:Support for class-based `config` is deprecated, use ConfigDict instead:DeprecationWarning:litellm.types.llms.anthropic", + # https://github.com/allenporter/python-google-nest-sdm/pull/1229 - >9.1.2 + "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google_nest_sdm.device", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", - # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:.*invalid escape sequence:SyntaxWarning:.*stringcase", # https://github.com/xchwarze/samsung-tv-ws-api/pull/151 - >2.7.2 - 2024-12-06 # wrong stacklevel in aiohttp "ignore:verify_ssl is deprecated, use ssl=False instead:DeprecationWarning:aiohttp.client", @@ -518,6 +518,8 @@ filterwarnings = [ # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 # https://github.com/martonperei/emulated_roku "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", + # https://github.com/EnergieID/energyid-webhooks-py/ - v0.0.14 - 2025-05-06 + "ignore:The V1 WebhookClient is deprecated:DeprecationWarning:energyid_webhooks", # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", # https://pypi.org/project/motionblindsble/ - v0.1.3 - 2024-11-12 @@ -565,13 +567,18 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", # - SyntaxWarning - is with literal # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + "ignore:\"is.*\" with '.*' literal:SyntaxWarning:.*lupupy.devices.alarm", # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + "ignore:\"is.*\" with '.*' literal:SyntaxWarning:.*opuslib.api.decoder", # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is.*\" with '.*' literal:SyntaxWarning:importlib._bootstrap", + "ignore:\"is.*\" with '.*' literal:SyntaxWarning:.*pyiss", # - SyntaxWarning - return in finally # https://github.com/nextcord/nextcord/pull/1268 - >3.1.1 - 2025-08-16 + "ignore:'return' in a 'finally' block:SyntaxWarning:.*nextcord.(gateway|player)", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:'return' in a 'finally' block:SyntaxWarning:importlib._bootstrap", + "ignore:'return' in a 'finally' block:SyntaxWarning:.*sleekxmppfs.(roster.single|xmlstream.xmlstream)", + # https://github.com/cereal2nd/velbus-aio/pull/153 - >2025.11.0 + "ignore:'return' in a 'finally' block:SyntaxWarning:.*velbusaio.vlp_reader", # -- New in Python 3.13 # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib @@ -597,6 +604,8 @@ filterwarnings = [ "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:(backoff._decorator|backoff._async)", # https://github.com/albertogeniola/elmax-api - v0.0.6.3 - 2024-11-30 "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:elmax_api.http", + # https://github.com/BerriAI/litellm - v1.80.9 - 2025-12-08 + "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:litellm.litellm_core_utils.logging_utils", # https://github.com/nextcord/nextcord/pull/1269 - >3.1.1 - 2025-08-16 "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:nextcord.member", # https://github.com/SteveEasley/pykaleidescape/pull/7 - v2022.2.6 - 2022-03-07 diff --git a/requirements.txt b/requirements.txt index 8047c614cce..816829ad137 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.6.0 +aiodns==3.6.1 aiohasupervisor==0.3.3 aiohttp==3.13.2 aiohttp_cors==0.8.1 @@ -46,7 +46,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 -uv==0.9.6 +uv==0.9.17 voluptuous==0.15.2 voluptuous-serialize==2.7.0 voluptuous-openapi==0.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index c2021de220b..866ffc956b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.16.2 +PySrDaliGateway==0.18.0 # homeassistant.components.switchbot PySwitchbot==0.74.0 @@ -133,7 +133,7 @@ WSDiscovery==2.1.2 accuweather==4.2.2 # homeassistant.components.actron_air -actron-neo-api==0.1.87 +actron-neo-api==0.4.1 # homeassistant.components.adax adax==0.4.0 @@ -206,7 +206,7 @@ aioaquacell==0.2.0 aioaseko==1.0.0 # homeassistant.components.asuswrt -aioasuswrt==1.5.1 +aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower aioautomower==2.7.1 @@ -231,7 +231,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 # homeassistant.components.dnsip -aiodns==3.6.0 +aiodns==3.6.1 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==43.0.0 +aioesphomeapi==43.3.0 # homeassistant.components.matrix # homeassistant.components.slack @@ -319,7 +319,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.0 +aiomealie==1.1.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -464,6 +464,9 @@ airly==1.1.0 # homeassistant.components.airos airos==0.6.0 +# homeassistant.components.airpatrol +airpatrol==0.1.0 + # homeassistant.components.airthings_ble airthings-ble==1.2.0 @@ -537,7 +540,7 @@ arris-tg2492lg==2.2.0 asmog==0.0.6 # homeassistant.components.asuswrt -asusrouter==1.21.1 +asusrouter==1.21.3 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -545,7 +548,7 @@ asusrouter==1.21.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.46.0 +async-upnp-client==0.46.1 # homeassistant.components.arve asyncarve==0.1.1 @@ -648,7 +651,7 @@ bleak==2.0.0 blebox-uniapi==2.5.0 # homeassistant.components.blink -blinkpy==0.24.1 +blinkpy==0.25.2 # homeassistant.components.bitcoin blockchain==1.4.4 @@ -739,7 +742,7 @@ colorlog==6.10.1 colorthief==0.2.1 # homeassistant.components.compit -compit-inext-api==0.3.1 +compit-inext-api==0.3.4 # homeassistant.components.concord232 concord232==0.15.1 @@ -857,7 +860,7 @@ egauge-async==0.4.0 eheimdigital==1.4.0 # homeassistant.components.ekeybionyx -ekey-bionyxpy==1.0.0 +ekey-bionyxpy==1.0.1 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -1099,7 +1102,7 @@ google-nest-sdm==9.1.2 google-photos-library-api==0.12.1 # homeassistant.components.google_air_quality -google_air_quality_api==2.0.0 +google_air_quality_api==2.0.2 # homeassistant.components.slide # homeassistant.components.slide_local @@ -1163,7 +1166,7 @@ habiticalib==0.4.6 habluetooth==5.8.0 # homeassistant.components.hanna -hanna-cloud==0.0.6 +hanna-cloud==0.0.7 # homeassistant.components.cloud hass-nabucasa==1.7.0 @@ -1246,7 +1249,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==11.1.0 +ical==12.1.2 # homeassistant.components.caldav icalendar==6.3.1 @@ -1479,7 +1482,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.mill -mill-local==0.3.0 +mill-local==0.5.0 # homeassistant.components.mill millheater==0.14.1 @@ -1494,7 +1497,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter -momonga==0.2.0 +momonga==0.3.0 # homeassistant.components.monzo monzopy==1.5.1 @@ -1572,7 +1575,7 @@ nextdns==4.1.0 nhc==0.7.0 # homeassistant.components.nibe_heatpump -nibe==2.19.0 +nibe==2.20.0 # homeassistant.components.nice_go nice-go==1.0.1 @@ -1651,7 +1654,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==2.8.0 +openai==2.11.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1742,7 +1745,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.10.0 +plugwise==1.11.0 # homeassistant.components.serial_pm pmsensor==0.4 @@ -1807,7 +1810,7 @@ py-dactyl==2.0.4 py-dormakaba-dkey==1.0.6 # homeassistant.components.improv_ble -py-improv-ble-client==1.0.3 +py-improv-ble-client==2.0.1 # homeassistant.components.madvr py-madvr2==1.6.40 @@ -1849,7 +1852,7 @@ pyElectra==1.2.4 pyEmby==1.10 # homeassistant.components.hikvision -pyHik==0.3.2 +pyHik==0.3.4 # homeassistant.components.homee pyHomee==1.3.8 @@ -1892,7 +1895,7 @@ pyairobotrest==0.1.0 pyairvisual==2023.08.1 # homeassistant.components.anglian_water -pyanglianwater==3.0.0 +pyanglianwater==3.1.0 # homeassistant.components.aprilaire pyaprilaire==0.9.1 @@ -1915,9 +1918,6 @@ pybalboa==1.1.3 # homeassistant.components.bbox pybbox==0.0.5-alpha -# homeassistant.components.blackbird -pyblackbird==0.6 - # homeassistant.components.bluesound pyblu==2.0.5 @@ -1946,7 +1946,7 @@ pycmus==0.1.1 pycomfoconnect==0.5.1 # homeassistant.components.coolmaster -pycoolmasternet-async==0.2.2 +pycoolmasternet-async==0.2.4 # homeassistant.components.radio_browser pycountry==24.6.1 @@ -1955,7 +1955,7 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.cync -pycync==0.4.3 +pycync==0.5.0 # homeassistant.components.daikin pydaikin==2.17.1 @@ -2058,7 +2058,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.17 +pyfritzhome==0.6.18 # homeassistant.components.ifttt pyfttt==0.3 @@ -2094,7 +2094,7 @@ pyialarm==2.2.0 pyicloud==2.2.0 # homeassistant.components.insteon -pyinsteon==1.6.3 +pyinsteon==1.6.4 # homeassistant.components.intesishome pyintesishome==1.8.0 @@ -2130,7 +2130,7 @@ pyituran==0.1.5 pyjvcprojector==1.1.2 # homeassistant.components.kaleidescape -pykaleidescape==1.0.1 +pykaleidescape==1.0.2 # homeassistant.components.kira pykira==0.1.1 @@ -2157,7 +2157,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.2.3 +pylamarzocco==2.2.4 # homeassistant.components.lastfm pylast==5.1.0 @@ -2229,7 +2229,10 @@ pynetio==0.1.9.1 pynina==0.3.6 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.0.0 +pynintendoauth==1.0.2 + +# homeassistant.components.nintendo_parental_controls +pynintendoparental==2.1.3 # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -2294,7 +2297,7 @@ pypaperless==4.1.1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.9.7 +pypck==0.9.8 # homeassistant.components.pglab pypglab==0.0.5 @@ -2397,7 +2400,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.assist_pipeline -pysilero-vad==3.0.0 +pysilero-vad==3.0.1 # homeassistant.components.sky_hub pyskyqhub==0.1.4 @@ -2412,7 +2415,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.5.0 +pysmartthings==3.5.1 # homeassistant.components.smarty pysmarty2==0.10.3 @@ -2424,7 +2427,7 @@ pysmhi==1.1.0 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.11 +pysmlight==0.2.13 # homeassistant.components.snmp pysnmp==7.1.22 @@ -2469,7 +2472,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==3.1.3 +python-bsblan==3.1.4 # homeassistant.components.citybikes python-citybikes==0.3.3 @@ -2508,7 +2511,7 @@ python-google-weather-api==0.0.4 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.3.0 +python-homewizard-energy==10.0.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2526,7 +2529,7 @@ python-kasa[speedups]==0.10.2 python-linkplay==0.2.12 # homeassistant.components.matter -python-matter-server==8.1.0 +python-matter-server==8.1.2 # homeassistant.components.melcloud python-melcloud==0.1.2 @@ -2554,7 +2557,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.1 +python-overseerr==0.8.0 # homeassistant.components.picnic python-picnic-api2==1.3.1 @@ -2569,10 +2572,10 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==3.10.10 +python-roborock==3.19.0 # homeassistant.components.smarttub -python-smarttub==0.0.45 +python-smarttub==0.0.46 # homeassistant.components.snoo python-snoo==0.8.3 @@ -2871,7 +2874,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.12 +soco==0.30.13 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2943,7 +2946,7 @@ switchbot-api==2.8.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==5.1.0 +systembridgeconnector==5.2.4 # homeassistant.components.tailscale tailscale==0.6.2 @@ -2972,7 +2975,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.5 +tesla-fleet-api==1.3.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2981,7 +2984,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.1.0 # homeassistant.components.teslemetry -teslemetry-stream==0.7.10 +teslemetry-stream==0.8.2 # homeassistant.components.tessie tessie-api==0.1.1 @@ -3120,6 +3123,9 @@ victron-vrm==0.1.8 # homeassistant.components.vilfo vilfo-api-client==0.5.0 +# homeassistant.components.watts +visionpluspython==1.0.2 + # homeassistant.components.caldav vobject==0.9.9 @@ -3197,7 +3203,7 @@ wyoming==1.7.2 xiaomi-ble==1.2.0 # homeassistant.components.knx -xknx==3.12.0 +xknx==3.13.0 # homeassistant.components.knx xknxproject==3.8.2 @@ -3258,7 +3264,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.80 +zha==0.0.81 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd3b736ad33..5d32f22af5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.16.2 +PySrDaliGateway==0.18.0 # homeassistant.components.switchbot PySwitchbot==0.74.0 @@ -124,7 +124,7 @@ WSDiscovery==2.1.2 accuweather==4.2.2 # homeassistant.components.actron_air -actron-neo-api==0.1.87 +actron-neo-api==0.4.1 # homeassistant.components.adax adax==0.4.0 @@ -197,7 +197,7 @@ aioaquacell==0.2.0 aioaseko==1.0.0 # homeassistant.components.asuswrt -aioasuswrt==1.5.1 +aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower aioautomower==2.7.1 @@ -222,7 +222,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 # homeassistant.components.dnsip -aiodns==3.6.0 +aiodns==3.6.1 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==43.0.0 +aioesphomeapi==43.3.0 # homeassistant.components.matrix # homeassistant.components.slack @@ -304,7 +304,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.0 +aiomealie==1.1.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -449,6 +449,9 @@ airly==1.1.0 # homeassistant.components.airos airos==0.6.0 +# homeassistant.components.airpatrol +airpatrol==0.1.0 + # homeassistant.components.airthings_ble airthings-ble==1.2.0 @@ -504,7 +507,7 @@ aranet4==2.5.1 arcam-fmj==1.8.2 # homeassistant.components.asuswrt -asusrouter==1.21.1 +asusrouter==1.21.3 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -512,7 +515,7 @@ asusrouter==1.21.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.46.0 +async-upnp-client==0.46.1 # homeassistant.components.arve asyncarve==0.1.1 @@ -585,7 +588,7 @@ bleak==2.0.0 blebox-uniapi==2.5.0 # homeassistant.components.blink -blinkpy==0.24.1 +blinkpy==0.25.2 # homeassistant.components.blue_current bluecurrent-api==1.3.2 @@ -654,7 +657,7 @@ colorlog==6.10.1 colorthief==0.2.1 # homeassistant.components.compit -compit-inext-api==0.3.1 +compit-inext-api==0.3.4 # homeassistant.components.concord232 concord232==0.15.1 @@ -757,7 +760,7 @@ egauge-async==0.4.0 eheimdigital==1.4.0 # homeassistant.components.ekeybionyx -ekey-bionyxpy==1.0.0 +ekey-bionyxpy==1.0.1 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -975,7 +978,7 @@ google-nest-sdm==9.1.2 google-photos-library-api==0.12.1 # homeassistant.components.google_air_quality -google_air_quality_api==2.0.0 +google_air_quality_api==2.0.2 # homeassistant.components.slide # homeassistant.components.slide_local @@ -1033,7 +1036,7 @@ habiticalib==0.4.6 habluetooth==5.8.0 # homeassistant.components.hanna -hanna-cloud==0.0.6 +hanna-cloud==0.0.7 # homeassistant.components.cloud hass-nabucasa==1.7.0 @@ -1098,7 +1101,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==11.1.0 +ical==12.1.2 # homeassistant.components.caldav icalendar==6.3.1 @@ -1286,7 +1289,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.mill -mill-local==0.3.0 +mill-local==0.5.0 # homeassistant.components.mill millheater==0.14.1 @@ -1301,7 +1304,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter -momonga==0.2.0 +momonga==0.3.0 # homeassistant.components.monzo monzopy==1.5.1 @@ -1367,7 +1370,7 @@ nextdns==4.1.0 nhc==0.7.0 # homeassistant.components.nibe_heatpump -nibe==2.19.0 +nibe==2.20.0 # homeassistant.components.nice_go nice-go==1.0.1 @@ -1434,7 +1437,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==2.8.0 +openai==2.11.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1494,7 +1497,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.10.0 +plugwise==1.11.0 # homeassistant.components.poolsense poolsense==0.0.8 @@ -1547,7 +1550,7 @@ py-dactyl==2.0.4 py-dormakaba-dkey==1.0.6 # homeassistant.components.improv_ble -py-improv-ble-client==1.0.3 +py-improv-ble-client==2.0.1 # homeassistant.components.madvr py-madvr2==1.6.40 @@ -1579,6 +1582,9 @@ pyDuotecno==2024.10.1 # homeassistant.components.electrasmart pyElectra==1.2.4 +# homeassistant.components.hikvision +pyHik==0.3.4 + # homeassistant.components.homee pyHomee==1.3.8 @@ -1611,7 +1617,7 @@ pyairobotrest==0.1.0 pyairvisual==2023.08.1 # homeassistant.components.anglian_water -pyanglianwater==3.0.0 +pyanglianwater==3.1.0 # homeassistant.components.aprilaire pyaprilaire==0.9.1 @@ -1631,9 +1637,6 @@ pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.1.3 -# homeassistant.components.blackbird -pyblackbird==0.6 - # homeassistant.components.bluesound pyblu==2.0.5 @@ -1650,7 +1653,7 @@ pycfdns==3.0.0 pycomfoconnect==0.5.1 # homeassistant.components.coolmaster -pycoolmasternet-async==0.2.2 +pycoolmasternet-async==0.2.4 # homeassistant.components.radio_browser pycountry==24.6.1 @@ -1659,7 +1662,7 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.cync -pycync==0.4.3 +pycync==0.5.0 # homeassistant.components.daikin pydaikin==2.17.1 @@ -1738,7 +1741,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.17 +pyfritzhome==0.6.18 # homeassistant.components.ifttt pyfttt==0.3 @@ -1768,7 +1771,7 @@ pyialarm==2.2.0 pyicloud==2.2.0 # homeassistant.components.insteon -pyinsteon==1.6.3 +pyinsteon==1.6.4 # homeassistant.components.ipma pyipma==3.0.9 @@ -1795,7 +1798,7 @@ pyituran==0.1.5 pyjvcprojector==1.1.2 # homeassistant.components.kaleidescape -pykaleidescape==1.0.1 +pykaleidescape==1.0.2 # homeassistant.components.kira pykira==0.1.1 @@ -1816,7 +1819,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.2.3 +pylamarzocco==2.2.4 # homeassistant.components.lastfm pylast==5.1.0 @@ -1876,7 +1879,10 @@ pynetgear==0.10.10 pynina==0.3.6 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.0.0 +pynintendoauth==1.0.2 + +# homeassistant.components.nintendo_parental_controls +pynintendoparental==2.1.3 # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -1932,7 +1938,7 @@ pypalazzetti==0.1.20 pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.9.7 +pypck==0.9.8 # homeassistant.components.pglab pypglab==0.0.5 @@ -2017,7 +2023,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.assist_pipeline -pysilero-vad==3.0.0 +pysilero-vad==3.0.1 # homeassistant.components.sma pysma==1.0.2 @@ -2029,7 +2035,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.5.0 +pysmartthings==3.5.1 # homeassistant.components.smarty pysmarty2==0.10.3 @@ -2041,7 +2047,7 @@ pysmhi==1.1.0 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.11 +pysmlight==0.2.13 # homeassistant.components.snmp pysnmp==7.1.22 @@ -2080,7 +2086,7 @@ python-MotionMount==2.3.0 python-awair==0.2.5 # homeassistant.components.bsblan -python-bsblan==3.1.3 +python-bsblan==3.1.4 # homeassistant.components.ecobee python-ecobee-api==0.3.2 @@ -2098,7 +2104,7 @@ python-google-weather-api==0.0.4 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.3.0 +python-homewizard-energy==10.0.0 # homeassistant.components.izone python-izone==1.2.9 @@ -2110,7 +2116,7 @@ python-kasa[speedups]==0.10.2 python-linkplay==0.2.12 # homeassistant.components.matter -python-matter-server==8.1.0 +python-matter-server==8.1.2 # homeassistant.components.melcloud python-melcloud==0.1.2 @@ -2138,7 +2144,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.1 +python-overseerr==0.8.0 # homeassistant.components.picnic python-picnic-api2==1.3.1 @@ -2150,10 +2156,10 @@ python-pooldose==0.8.1 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==3.10.10 +python-roborock==3.19.0 # homeassistant.components.smarttub -python-smarttub==0.0.45 +python-smarttub==0.0.46 # homeassistant.components.snoo python-snoo==0.8.3 @@ -2395,7 +2401,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.12 +soco==0.30.13 # homeassistant.components.solaredge solaredge-web==0.0.1 @@ -2455,7 +2461,7 @@ surepy==0.9.0 switchbot-api==2.8.0 # homeassistant.components.system_bridge -systembridgeconnector==5.1.0 +systembridgeconnector==5.2.4 # homeassistant.components.tailscale tailscale==0.6.2 @@ -2472,7 +2478,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.5 +tesla-fleet-api==1.3.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2481,7 +2487,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.1.0 # homeassistant.components.teslemetry -teslemetry-stream==0.7.10 +teslemetry-stream==0.8.2 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2602,6 +2608,9 @@ victron-vrm==0.1.8 # homeassistant.components.vilfo vilfo-api-client==0.5.0 +# homeassistant.components.watts +visionpluspython==1.0.2 + # homeassistant.components.caldav vobject==0.9.9 @@ -2664,7 +2673,7 @@ wyoming==1.7.2 xiaomi-ble==1.2.0 # homeassistant.components.knx -xknx==3.12.0 +xknx==3.13.0 # homeassistant.components.knx xknxproject==3.8.2 @@ -2716,7 +2725,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.80 +zha==0.0.81 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 0f8b2dd58c9..f49dbbc7064 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -12,7 +12,7 @@ from .model import Config, Integration from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR _GO2RTC_SHA = ( - "baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640" # 1.9.12 + "f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241" # 1.9.13 ) DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index a136067e69b..c5382a4a966 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.9.17,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG @@ -33,7 +33,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \ hassil==3.5.0 \ home-assistant-intents==2025.12.2 \ mutagen==1.47.0 \ - pysilero-vad==3.0.0 \ + pysilero-vad==3.0.1 \ pyspeex-noise==1.0.2 LABEL "name"="hassfest" diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cf88614128f..5cb0fffddf1 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -905,7 +905,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "splunk", "spotify", "sql", - "squeezebox", "srp_energy", "ssdp", "starline", @@ -984,7 +983,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "touchline", "touchline_sl", "tplink_lte", - "tplink_omada", "traccar", "traccar_server", "tractive", @@ -1177,7 +1175,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "aten_pe", "atome", "august", - "autarco", "aurora", "aurora_abb_powerone", "aussie_broadband", @@ -1926,7 +1923,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "splunk", "spotify", "sql", - "squeezebox", "srp_energy", "ssdp", "starline", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 33604837567..f13654d751c 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -168,11 +168,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # influxdb-client > setuptools "influxdb-client": {"setuptools"} }, - "insteon": { - # https://github.com/pyinsteon/pyinsteon/issues/430 - # pyinsteon > pyserial-asyncio - "pyinsteon": {"pyserial-asyncio"} - }, "izone": {"python-izone": {"async-timeout"}}, "keba": { # https://github.com/jsbronder/asyncio-dgram/issues/20 @@ -189,7 +184,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "python-linkplay": {"async-timeout"}, }, "loqed": {"loqedapi": {"async-timeout"}}, - "matter": {"python-matter-server": {"async-timeout"}}, "mediaroom": {"pymediaroom": {"async-timeout"}}, "met": {"pymetno": {"async-timeout"}}, "met_eireann": {"pymeteireann": {"async-timeout"}}, diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index e738e8f0911..6b54f0fae48 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -267,7 +267,7 @@ async def test_login_flow( # not from trusted network flow = await provider.async_login_flow({"ip_address": ip_address("127.0.0.1")}) step = await flow.async_step_init() - assert step["type"] == FlowResultType.ABORT + assert step["type"] is FlowResultType.ABORT assert step["reason"] == "not_allowed" # from trusted network, list users @@ -282,7 +282,7 @@ async def test_login_flow( # login with valid user step = await flow.async_step_init({"user": user.id}) - assert step["type"] == FlowResultType.CREATE_ENTRY + assert step["type"] is FlowResultType.CREATE_ENTRY assert step["data"]["user"] == user.id @@ -309,7 +309,7 @@ async def test_trusted_users_login( {"ip_address": ip_address("127.0.0.1")} ) step = await flow.async_step_init() - assert step["type"] == FlowResultType.ABORT + assert step["type"] is FlowResultType.ABORT assert step["reason"] == "not_allowed" # from trusted network, list users intersect trusted_users @@ -396,7 +396,7 @@ async def test_trusted_group_login( {"ip_address": ip_address("127.0.0.1")} ) step = await flow.async_step_init() - assert step["type"] == FlowResultType.ABORT + assert step["type"] is FlowResultType.ABORT assert step["reason"] == "not_allowed" # from trusted network, list users intersect trusted_users @@ -437,7 +437,7 @@ async def test_bypass_login_flow( {"ip_address": ip_address("127.0.0.1")} ) step = await flow.async_step_init() - assert step["type"] == FlowResultType.ABORT + assert step["type"] is FlowResultType.ABORT assert step["reason"] == "not_allowed" # from trusted network, only one available user, bypass the login flow @@ -445,7 +445,7 @@ async def test_bypass_login_flow( {"ip_address": ip_address("192.168.0.1")} ) step = await flow.async_step_init() - assert step["type"] == FlowResultType.CREATE_ENTRY + assert step["type"] is FlowResultType.CREATE_ENTRY assert step["data"]["user"] == owner.id user = await manager_bypass_login.async_create_user("test-user") diff --git a/tests/components/actron_air/__init__.py b/tests/components/actron_air/__init__.py index c2f40057ab7..235476db98c 100644 --- a/tests/components/actron_air/__init__.py +++ b/tests/components/actron_air/__init__.py @@ -1 +1,13 @@ """Tests for the Actron Air integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py index 3c9a5b2eed9..6f1b4869882 100644 --- a/tests/components/actron_air/conftest.py +++ b/tests/components/actron_air/conftest.py @@ -2,10 +2,15 @@ import asyncio from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.actron_air.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN + +from tests.common import MockConfigEntry + @pytest.fixture def mock_actron_api() -> Generator[AsyncMock]: @@ -48,8 +53,59 @@ def mock_actron_api() -> Generator[AsyncMock]: # Mock refresh token property api.refresh_token_value = "test_refresh_token" - # Mock other API methods that might be used - api.get_systems = AsyncMock(return_value=[]) - api.get_status = AsyncMock(return_value=None) + # Mock get_ac_systems + api.get_ac_systems = AsyncMock( + return_value=[{"serial": "123456", "name": "Test System"}] + ) + + # Mock state manager + api.state_manager = MagicMock() + status = api.state_manager.get_status.return_value + status.master_info.live_temp_c = 22.0 + status.ac_system.system_name = "Test System" + status.ac_system.serial_number = "123456" + status.ac_system.master_wc_model = "Test Model" + status.ac_system.master_wc_firmware_version = "1.0.0" + status.remote_zone_info = [] + status.min_temp = 16 + status.max_temp = 30 + status.aircon_system.mode = "OFF" + status.fan_mode = "LOW" + status.set_point = 24 + status.room_temp = 25 + status.is_on = False + + # Mock user_aircon_settings for the switch platform + settings = status.user_aircon_settings + settings.away_mode = False + settings.continuous_fan_enabled = False + settings.quiet_mode_enabled = False + settings.turbo_enabled = False + settings.turbo_supported = True + + settings.set_away_mode = AsyncMock() + settings.set_continuous_mode = AsyncMock() + settings.set_quiet_mode = AsyncMock() + settings.set_turbo_mode = AsyncMock() yield api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="test@example.com", + data={CONF_API_TOKEN: "test_refresh_token"}, + unique_id="test_user_id", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.actron_air.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/actron_air/snapshots/test_switch.ambr b/tests/components/actron_air/snapshots/test_switch.ambr new file mode 100644 index 00000000000..5735835c8ea --- /dev/null +++ b/tests/components/actron_air/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_switch_entities[switch.test_system_away_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_system_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away mode', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'away_mode', + 'unique_id': '123456_away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Away mode', + }), + 'context': , + 'entity_id': 'switch.test_system_away_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.test_system_continuous_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_system_continuous_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Continuous fan', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'continuous_fan', + 'unique_id': '123456_continuous_fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_continuous_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Continuous fan', + }), + 'context': , + 'entity_id': 'switch.test_system_continuous_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.test_system_quiet_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_system_quiet_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quiet mode', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quiet_mode', + 'unique_id': '123456_quiet_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_quiet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Quiet mode', + }), + 'context': , + 'entity_id': 'switch.test_system_quiet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.test_system_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_system_turbo_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Turbo mode', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turbo_mode', + 'unique_id': '123456_turbo_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Turbo mode', + }), + 'context': , + 'entity_id': 'switch.test_system_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/actron_air/test_config_flow.py b/tests/components/actron_air/test_config_flow.py index 848bd210411..113af461c89 100644 --- a/tests/components/actron_air/test_config_flow.py +++ b/tests/components/actron_air/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_user_flow_oauth2_success( - hass: HomeAssistant, mock_actron_api: AsyncMock + hass: HomeAssistant, mock_actron_api: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test successful OAuth2 device code flow.""" # Start the config flow @@ -90,7 +90,7 @@ async def test_user_flow_oauth2_error(hass: HomeAssistant, mock_actron_api) -> N async def test_user_flow_token_polling_error( - hass: HomeAssistant, mock_actron_api + hass: HomeAssistant, mock_actron_api, mock_setup_entry: AsyncMock ) -> None: """Test OAuth2 flow with error during token polling.""" # Override the default mock to raise an error during token polling @@ -148,17 +148,11 @@ async def test_user_flow_token_polling_error( async def test_user_flow_duplicate_account( - hass: HomeAssistant, mock_actron_api: AsyncMock + hass: HomeAssistant, mock_actron_api: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test duplicate account handling - should abort when same account is already configured.""" # Create an existing config entry for the same user account - existing_entry = MockConfigEntry( - domain=DOMAIN, - title="test@example.com", - data={CONF_API_TOKEN: "existing_refresh_token"}, - unique_id="test_user_id", - ) - existing_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) # Start the config flow result = await hass.config_entries.flow.async_init( @@ -180,5 +174,81 @@ async def test_user_flow_duplicate_account( result = await hass.config_entries.flow.async_configure(result["flow_id"]) # Should abort because the account is already configured - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test successful reauthentication flow.""" + # Create an existing config entry + mock_config_entry.add_to_hass(hass) + existing_entry = mock_config_entry + + # Start the reauth flow + result = await mock_config_entry.start_reauth_flow(hass) + + # Should show the reauth confirmation form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Submit the confirmation form to start the OAuth flow + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Should start with a progress step + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Continue the flow after progress is done + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should update the existing entry with new token + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert existing_entry.data[CONF_API_TOKEN] == "test_refresh_token" + + +async def test_reauth_flow_wrong_account( + hass: HomeAssistant, mock_actron_api: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test reauthentication flow with wrong account.""" + # Create an existing config entry + mock_config_entry.add_to_hass(hass) + + # Mock the API to return a different user ID + mock_actron_api.get_user_info = AsyncMock( + return_value={"id": "different_user_id", "email": "different@example.com"} + ) + + # Start the reauth flow + result = await mock_config_entry.start_reauth_flow(hass) + + # Should show the reauth confirmation form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Submit the confirmation form + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Should start with a progress step + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Continue the flow after progress is done + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should abort because of wrong account + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/actron_air/test_switch.py b/tests/components/actron_air/test_switch.py new file mode 100644 index 00000000000..2464ae8d0a0 --- /dev/null +++ b/tests/components/actron_air/test_switch.py @@ -0,0 +1,90 @@ +"""Tests for the Actron Air switch platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switch_entities( + hass: HomeAssistant, + mock_actron_api: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("switch.test_system_away_mode", "set_away_mode"), + ("switch.test_system_continuous_fan", "set_continuous_mode"), + ("switch.test_system_quiet_mode", "set_quiet_mode"), + ("switch.test_system_turbo_mode", "set_turbo_mode"), + ], +) +async def test_switch_toggles( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, +) -> None: + """Test switch toggles.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + status = mock_actron_api.state_manager.get_status.return_value + mock_method = getattr(status.user_aircon_settings, method) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_method.assert_awaited_once_with(True) + mock_method.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_method.assert_awaited_once_with(False) + + +async def test_turbo_mode_not_supported( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test turbo mode switch is not created when not supported.""" + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.turbo_supported = False + + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.test_system_turbo_mode" + assert not hass.states.get(entity_id) + assert not entity_registry.async_get(entity_id) diff --git a/tests/components/airobot/snapshots/test_sensor.ambr b/tests/components/airobot/snapshots/test_sensor.ambr index e168d3718c5..91e56125332 100644 --- a/tests/components/airobot/snapshots/test_sensor.ambr +++ b/tests/components/airobot/snapshots/test_sensor.ambr @@ -55,6 +55,55 @@ 'state': '22.0', }) # --- +# name: test_sensors[sensor.test_thermostat_device_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_thermostat_device_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Device uptime', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_uptime', + 'unique_id': 'T01A1B2C3_device_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_thermostat_device_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Thermostat Device uptime', + }), + 'context': , + 'entity_id': 'sensor.test_thermostat_device_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-12-31T21:13:20+00:00', + }) +# --- # name: test_sensors[sensor.test_thermostat_error_count-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/airobot/test_sensor.py b/tests/components/airobot/test_sensor.py index 66930b481da..3196a2331e7 100644 --- a/tests/components/airobot/test_sensor.py +++ b/tests/components/airobot/test_sensor.py @@ -16,6 +16,7 @@ def platforms() -> list[Platform]: return [Platform.SENSOR] +@pytest.mark.freeze_time("2024-01-01 00:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_sensors( hass: HomeAssistant, diff --git a/tests/components/airpatrol/__init__.py b/tests/components/airpatrol/__init__.py new file mode 100644 index 00000000000..19f789f5b5a --- /dev/null +++ b/tests/components/airpatrol/__init__.py @@ -0,0 +1 @@ +"""Tests for the AirPatrol integration.""" diff --git a/tests/components/airpatrol/conftest.py b/tests/components/airpatrol/conftest.py new file mode 100644 index 00000000000..036cd4ba3d6 --- /dev/null +++ b/tests/components/airpatrol/conftest.py @@ -0,0 +1,98 @@ +"""Common fixtures for the AirPatrol tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from airpatrol.api import AirPatrolAPI +import pytest + +from homeassistant.components.airpatrol.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DEFAULT_UNIT_ID = "test_unit_001" + + +@pytest.fixture(name="get_client") +def mock_airpatrol_client(get_data) -> Generator[AsyncMock]: + """Mock an AirPatrol client and config.""" + with ( + patch( + "homeassistant.components.airpatrol.coordinator.AirPatrolAPI", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.airpatrol.config_flow.AirPatrolAPI", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_unique_id.return_value = "test_user_id" + client.get_access_token.return_value = "test_access_token" + client.get_data.return_value = get_data + client.set_unit_climate_data.return_value = AsyncMock() + mock_client.authenticate.return_value = client + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test_password", + CONF_ACCESS_TOKEN: "test_access_token", + }, + unique_id="test_user_id", + title="test@example.com", + ) + + +@pytest.fixture +async def load_integration( + hass: HomeAssistant, + get_client: AirPatrolAPI, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Load the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry + + +@pytest.fixture +def get_data(climate_data: dict[str, Any]) -> list[dict[str, Any]]: + """Return data.""" + return [ + { + "unit_id": DEFAULT_UNIT_ID, + "name": "living room", + "manufacturer": "AirPatrol", + "model": "apw", + "hwid": "hw01", + "climate": climate_data, + } + ] + + +@pytest.fixture +def climate_data() -> dict[str, Any]: + """Return data.""" + return { + "ParametersData": { + "PumpPower": "on", + "PumpTemp": "22.000", + "PumpMode": "cool", + "FanSpeed": "max", + "Swing": "off", + }, + "RoomTemp": "22.5", + "RoomHumidity": "45", + } diff --git a/tests/components/airpatrol/snapshots/test_climate.ambr b/tests/components/airpatrol/snapshots/test_climate.ambr new file mode 100644 index 00000000000..9495481c0e8 --- /dev/null +++ b/tests/components/airpatrol/snapshots/test_climate.ambr @@ -0,0 +1,170 @@ +# serializer version: 1 +# name: test_climate_entities[None][climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'high', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'swing_modes': list([ + 'on', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'airpatrol', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test_user_id-test_unit_001', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[None][climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'low', + 'high', + 'auto', + ]), + 'friendly_name': 'living room', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'supported_features': , + 'swing_modes': list([ + 'on', + 'off', + ]), + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_climate_entities[climate_data0][climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'high', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'swing_modes': list([ + 'on', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'airpatrol', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test_user_id-test_unit_001', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[climate_data0][climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 45.0, + 'current_temperature': 22.5, + 'fan_mode': 'high', + 'fan_modes': list([ + 'low', + 'high', + 'auto', + ]), + 'friendly_name': 'living room', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/airpatrol/snapshots/test_sensor.ambr b/tests/components/airpatrol/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3a142cf34f6 --- /dev/null +++ b/tests/components/airpatrol/snapshots/test_sensor.ambr @@ -0,0 +1,110 @@ +# serializer version: 1 +# name: test_sensor_with_climate_data[sensor.living_room_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airpatrol', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_user_id-test_unit_001-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_with_climate_data[sensor.living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'living room Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- +# name: test_sensor_with_climate_data[sensor.living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airpatrol', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_user_id-test_unit_001-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_with_climate_data[sensor.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'living room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- diff --git a/tests/components/airpatrol/test_climate.py b/tests/components/airpatrol/test_climate.py new file mode 100644 index 00000000000..198c1152c06 --- /dev/null +++ b/tests/components/airpatrol/test_climate.py @@ -0,0 +1,383 @@ +"""Test the AirPatrol climate platform.""" + +from collections.abc import Generator +from datetime import timedelta +from typing import Any +from unittest.mock import patch + +from airpatrol.api import AirPatrolAPI +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.airpatrol.climate import ( + HA_TO_AP_FAN_MODES, + HA_TO_AP_HVAC_MODES, + HA_TO_AP_SWING_MODES, +) +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_SWING_MODE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + FAN_LOW, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SWING_OFF, + SWING_ON, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + SnapshotAssertion, + async_fire_time_changed, + snapshot_platform, +) + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override the platforms to load for airpatrol.""" + with patch( + "homeassistant.components.airpatrol.PLATFORMS", + [Platform.CLIMATE], + ): + yield + + +@pytest.mark.parametrize( + "climate_data", + [ + { + "ParametersData": { + "PumpPower": "on", + "PumpTemp": "22.000", + "PumpMode": "cool", + "FanSpeed": "max", + "Swing": "off", + }, + "RoomTemp": "22.5", + "RoomHumidity": "45", + }, + None, + ], +) +async def test_climate_entities( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test climate.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + load_integration.entry_id, + ) + + +async def test_climate_entity_unavailable( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + get_data: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test climate entity when climate data is missing.""" + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + + get_data[0]["climate"] = None + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == "unavailable" + + +async def test_climate_set_temperature( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + climate_data: dict[str, Any], +) -> None: + """Test setting temperature.""" + TARGET_TEMP = 25.0 + + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + climate_data["ParametersData"]["PumpTemp"] = f"{TARGET_TEMP:.3f}" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + CONF_ENTITY_ID: state.entity_id, + ATTR_TEMPERATURE: TARGET_TEMP, + }, + ) + + get_client.set_unit_climate_data.assert_called_once() + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_TEMPERATURE] == TARGET_TEMP + + +async def test_climate_set_hvac_mode( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + climate_data: dict[str, Any], +) -> None: + """Test setting HVAC mode.""" + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.COOL + + climate_data["ParametersData"]["PumpMode"] = HA_TO_AP_HVAC_MODES[HVACMode.HEAT] + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + CONF_ENTITY_ID: state.entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + ) + + get_client.set_unit_climate_data.assert_called_once() + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.HEAT + + +async def test_climate_set_fan_mode( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + climate_data: dict[str, Any], +) -> None: + """Test setting fan mode.""" + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + + climate_data["ParametersData"]["FanSpeed"] = HA_TO_AP_FAN_MODES[FAN_LOW] + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + CONF_ENTITY_ID: state.entity_id, + ATTR_FAN_MODE: FAN_LOW, + }, + ) + + get_client.set_unit_climate_data.assert_called_once() + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_FAN_MODE] == FAN_LOW + + +async def test_climate_set_swing_mode( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + climate_data: dict[str, Any], +) -> None: + """Test setting swing mode.""" + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_SWING_MODE] == SWING_OFF + + climate_data["ParametersData"]["Swing"] = HA_TO_AP_SWING_MODES[SWING_ON] + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + { + CONF_ENTITY_ID: state.entity_id, + ATTR_SWING_MODE: SWING_ON, + }, + ) + + get_client.set_unit_climate_data.assert_called_once() + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_SWING_MODE] == SWING_ON + + +@pytest.mark.parametrize( + "climate_data", + [ + { + "ParametersData": { + "PumpPower": "off", + "PumpTemp": "22.000", + "PumpMode": "cool", + "FanSpeed": "max", + "Swing": "off", + }, + "RoomTemp": "22.5", + "RoomHumidity": "45", + } + ], +) +async def test_climate_turn_on( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + climate_data: dict[str, Any], +) -> None: + """Test turning climate on.""" + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.OFF + + climate_data["ParametersData"]["PumpPower"] = "on" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + CONF_ENTITY_ID: state.entity_id, + }, + ) + + get_client.set_unit_climate_data.assert_called_once() + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.COOL + + +async def test_climate_turn_off( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + climate_data: dict[str, Any], +) -> None: + """Test turning climate off.""" + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.COOL + + climate_data["ParametersData"]["PumpPower"] = "off" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + CONF_ENTITY_ID: state.entity_id, + }, + ) + + get_client.set_unit_climate_data.assert_called_once() + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize( + "climate_data", + [ + { + "ParametersData": { + "PumpPower": "on", + "PumpTemp": "22.000", + "PumpMode": "heat", + "FanSpeed": "max", + "Swing": "off", + }, + "RoomTemp": "22.5", + "RoomHumidity": "45", + } + ], +) +async def test_climate_heat_mode( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, +) -> None: + """Test climate in heat mode.""" + state = hass.states.get("climate.living_room") + assert state.state == HVACMode.HEAT + + +async def test_climate_set_temperature_api_error( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, +) -> None: + """Test async_set_temperature handles API error.""" + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + get_client.set_unit_climate_data.side_effect = Exception("API Error") + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + CONF_ENTITY_ID: state.entity_id, + ATTR_TEMPERATURE: 25.0, + }, + ) + + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + +@pytest.mark.parametrize( + "climate_data", + [ + { + "ParametersData": { + "PumpPower": "off", + "PumpTemp": "22.000", + "PumpMode": "cool", + "FanSpeed": "sideways", + "Swing": "off", + }, + "RoomTemp": "22.5", + "RoomHumidity": "45", + } + ], +) +async def test_climate_fan_mode_invalid( + hass: HomeAssistant, + get_client: AirPatrolAPI, + get_data: dict[str, Any], + load_integration: MockConfigEntry, +) -> None: + """Test fan_mode with unexpected value.""" + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_FAN_MODE] is None + + +@pytest.mark.parametrize( + "climate_data", + [ + { + "ParametersData": { + "PumpPower": "off", + "PumpTemp": "22.000", + "PumpMode": "cool", + "FanSpeed": "max", + "Swing": "sideways", + }, + "RoomTemp": "22.5", + "RoomHumidity": "45", + } + ], +) +async def test_climate_swing_mode_invalid( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, +) -> None: + """Test swing_mode with unexpected value.""" + state = hass.states.get("climate.living_room") + assert state.attributes[ATTR_SWING_MODE] is None diff --git a/tests/components/airpatrol/test_config_flow.py b/tests/components/airpatrol/test_config_flow.py new file mode 100644 index 00000000000..188c1767efd --- /dev/null +++ b/tests/components/airpatrol/test_config_flow.py @@ -0,0 +1,182 @@ +"""Test the AirPatrol config flow.""" + +from unittest.mock import patch + +from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError +import pytest + +from homeassistant.components.airpatrol.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_USER_INPUT = { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test_password", +} + + +async def test_user_flow_success( + hass: HomeAssistant, + get_client: AirPatrolAPI, +) -> None: + """Test successful user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USER_INPUT[CONF_EMAIL] + assert result["data"] == { + **TEST_USER_INPUT, + CONF_ACCESS_TOKEN: "test_access_token", + } + assert result["result"].unique_id == "test_user_id" + + +async def test_async_step_reauth_confirm_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, get_client: AirPatrolAPI +) -> None: + """Test successful reauthentication via async_step_reauth_confirm.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_PASSWORD] == "test_password" + assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "test_access_token" + + +async def test_async_step_reauth_confirm_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + get_client: AirPatrolAPI, +) -> None: + """Test reauthentication failure due to invalid credentials.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.airpatrol.config_flow.AirPatrolAPI.authenticate", + side_effect=AirPatrolAuthenticationError("fail"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "test_password" + assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "test_access_token" + + +async def test_async_step_reauth_confirm_another_account_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, get_client: AirPatrolAPI +) -> None: + """Test reauthentication failure due to another account.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + get_client.get_unique_id.return_value = "different_user_id" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test2@example.com", CONF_PASSWORD: "test_password2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (AirPatrolError("fail"), "cannot_connect"), + (AirPatrolAuthenticationError("fail"), "invalid_auth"), + ], +) +async def test_user_flow_error( + hass: HomeAssistant, + side_effect, + expected_error, + get_client: AirPatrolAPI, +) -> None: + """Test user flow with invalid authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.airpatrol.config_flow.AirPatrolAPI.authenticate", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USER_INPUT[CONF_EMAIL] + assert result["data"] == { + **TEST_USER_INPUT, + CONF_ACCESS_TOKEN: "test_access_token", + } + assert result["result"].unique_id == "test_user_id" + + +async def test_user_flow_already_configured( + hass: HomeAssistant, + get_client: AirPatrolAPI, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user flow when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/airpatrol/test_init.py b/tests/components/airpatrol/test_init.py new file mode 100644 index 00000000000..af99dc2186b --- /dev/null +++ b/tests/components/airpatrol/test_init.py @@ -0,0 +1,76 @@ +"""Test the AirPatrol integration setup.""" + +from unittest.mock import patch + +from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, State + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + get_client: AirPatrolAPI, +) -> None: + """Test loading and unloading the config entry.""" + # Add the config entry to hass first + mock_config_entry.add_to_hass(hass) + + # Load the config entry + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_update_data_refresh_token_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + get_client: AirPatrolAPI, + get_data, +) -> None: + """Test data update with expired token and successful token refresh.""" + get_client.get_data.side_effect = [ + AirPatrolAuthenticationError("fail"), + get_data, + ] + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert get_client.get_data.call_count == 2 + + assert hass.states.get("climate.living_room") + + +async def test_update_data_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + get_client: AirPatrolAPI, +) -> None: + """Test permanent authentication failure.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airpatrol.coordinator.AirPatrolAPI.authenticate", + side_effect=AirPatrolAuthenticationError("fail"), + ): + get_client.get_data.side_effect = AirPatrolAuthenticationError("fail") + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state: State | None = hass.states.get("climate.living_room") + assert state is None + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.reason == "Authentication with AirPatrol failed" diff --git a/tests/components/airpatrol/test_sensor.py b/tests/components/airpatrol/test_sensor.py new file mode 100644 index 00000000000..67ff9919cf6 --- /dev/null +++ b/tests/components/airpatrol/test_sensor.py @@ -0,0 +1,55 @@ +"""Test the AirPatrol sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from airpatrol.api import AirPatrolAPI +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override the platforms to load for airpatrol.""" + with patch( + "homeassistant.components.airpatrol.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +async def test_sensor_with_climate_data( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor entities are created with climate data.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + load_integration.entry_id, + ) + + +@pytest.mark.parametrize( + "climate_data", + [ + None, + ], +) +async def test_sensor_with_no_climate_data( + hass: HomeAssistant, + load_integration: MockConfigEntry, + get_client: AirPatrolAPI, + entity_registry: er.EntityRegistry, +) -> None: + """Test no sensor entities are created when no climate data is present.""" + assert len(entity_registry.entities) == 0 diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 66cacecdaaa..35862531aca 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -119,7 +119,7 @@ async def test_options_flow(hass: HomeAssistant, user_input) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert entry.options == {} @@ -127,5 +127,5 @@ async def test_options_flow(hass: HomeAssistant, user_input) -> None: result["flow_id"], user_input=user_input ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == entry.options == DEFAULT_OPTIONS | user_input diff --git a/tests/components/ambient_network/test_config_flow.py b/tests/components/ambient_network/test_config_flow.py index d9093de7234..abe84067252 100644 --- a/tests/components/ambient_network/test_config_flow.py +++ b/tests/components/ambient_network/test_config_flow.py @@ -28,7 +28,7 @@ async def test_happy_path( setup_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert setup_result["type"] == FlowResultType.FORM + assert setup_result["type"] is FlowResultType.FORM assert setup_result["step_id"] == "user" with patch.object( @@ -41,7 +41,7 @@ async def test_happy_path( {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, ) - assert user_result["type"] == FlowResultType.FORM + assert user_result["type"] is FlowResultType.FORM assert user_result["step_id"] == "station" stations_result = await hass.config_entries.flow.async_configure( @@ -51,7 +51,7 @@ async def test_happy_path( }, ) - assert stations_result["type"] == FlowResultType.CREATE_ENTRY + assert stations_result["type"] is FlowResultType.CREATE_ENTRY assert stations_result["title"] == config_entry.title assert stations_result["data"] == config_entry.data assert len(mock_setup_entry.mock_calls) == 1 @@ -67,7 +67,7 @@ async def test_no_station_found( setup_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert setup_result["type"] == FlowResultType.FORM + assert setup_result["type"] is FlowResultType.FORM assert setup_result["step_id"] == "user" with patch.object( @@ -80,6 +80,6 @@ async def test_no_station_found( {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, ) - assert user_result["type"] == FlowResultType.FORM + assert user_result["type"] is FlowResultType.FORM assert user_result["step_id"] == "user" assert user_result["errors"] == {"base": "no_stations_found"} diff --git a/tests/components/anglian_water/conftest.py b/tests/components/anglian_water/conftest.py index be7b606a56e..f206727ad4a 100644 --- a/tests/components/anglian_water/conftest.py +++ b/tests/components/anglian_water/conftest.py @@ -38,6 +38,11 @@ def mock_smart_meter() -> SmartMeter: mock.latest_read = 50 mock.yesterday_water_cost = 0.5 mock.yesterday_sewerage_cost = 0.5 + mock.readings = [ + {"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10}, + {"read_at": "2024-06-01T13:00:00Z", "consumption": 15, "read": 25}, + {"read_at": "2024-06-01T14:00:00Z", "consumption": 25, "read": 50}, + ] return mock diff --git a/tests/components/anglian_water/snapshots/test_coordinator.ambr b/tests/components/anglian_water/snapshots/test_coordinator.ambr new file mode 100644 index 00000000000..5940fb01994 --- /dev/null +++ b/tests/components/anglian_water/snapshots/test_coordinator.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_coordinator_first_run + defaultdict({ + 'anglian_water:12345678_testsn_usage': list([ + dict({ + 'end': 1717243200.0, + 'start': 1717239600.0, + 'state': 0.01, + 'sum': 10.0, + }), + dict({ + 'end': 1717246800.0, + 'start': 1717243200.0, + 'state': 0.015, + 'sum': 25.0, + }), + dict({ + 'end': 1717250400.0, + 'start': 1717246800.0, + 'state': 0.025, + 'sum': 50.0, + }), + ]), + }) +# --- +# name: test_coordinator_subsequent_run + defaultdict({ + 'anglian_water:12345678_testsn_usage': list([ + dict({ + 'end': 1717243200.0, + 'start': 1717239600.0, + 'state': 0.01, + 'sum': 10.0, + }), + dict({ + 'end': 1717246800.0, + 'start': 1717243200.0, + 'state': 0.015, + 'sum': 25.0, + }), + dict({ + 'end': 1717250400.0, + 'start': 1717246800.0, + 'state': 0.035, + 'sum': 70.0, + }), + dict({ + 'end': 1717254000.0, + 'start': 1717250400.0, + 'state': 0.02, + 'sum': 90.0, + }), + ]), + }) +# --- diff --git a/tests/components/anglian_water/test_coordinator.py b/tests/components/anglian_water/test_coordinator.py new file mode 100644 index 00000000000..1072b531218 --- /dev/null +++ b/tests/components/anglian_water/test_coordinator.py @@ -0,0 +1,164 @@ +"""Tests for the Anglian Water coordinator.""" + +from unittest.mock import AsyncMock + +from pyanglianwater.meter import SmartMeter +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.anglian_water.coordinator import ( + AnglianWaterUpdateCoordinator, +) +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import ( + get_last_statistics, + statistics_during_period, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import ACCOUNT_NUMBER + +from tests.common import MockConfigEntry +from tests.components.recorder.common import async_wait_recording_done + + +async def test_coordinator_first_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_anglian_water_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator on its first run with no existing statistics.""" + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + +async def test_coordinator_subsequent_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smart_meter: SmartMeter, + mock_anglian_water_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator correctly updates statistics on subsequent runs.""" + # 1st run + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # 2nd run with an updated reading for one read and a new read added. + mock_smart_meter.readings[-1] = { + "read_at": "2024-06-01T14:00:00Z", + "consumption": 35, + "read": 70, + } + mock_smart_meter.readings.append( + {"read_at": "2024-06-01T15:00:00Z", "consumption": 20, "read": 90} + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Check all stats + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + +async def test_coordinator_subsequent_run_no_energy_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smart_meter: SmartMeter, + mock_anglian_water_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles no recent usage/cost data.""" + # 1st run + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # 2nd run with no readings + mock_smart_meter.readings = [] + await coordinator._async_update_data() + + assert "No recent usage statistics found, skipping update" in caplog.text + # Verify no new stats were added by checking the sum remains 50 + statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage" + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id][0]["sum"] == 50 + + +async def test_coordinator_invalid_readings( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smart_meter: SmartMeter, + mock_anglian_water_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles bad data / invalid readings correctly.""" + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Test that an invalid read_at on the first reading skips the entire update + mock_smart_meter.readings = [ + {"read_at": "invalid-date-format", "consumption": 10, "read": 10}, + ] + await coordinator._async_update_data() + + assert ( + "Could not parse read_at time invalid-date-format, skipping update" + in caplog.text + ) + + # Test that individual invalid readings are skipped + mock_smart_meter.readings = [ + {"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10}, + {"read_at": "also-invalid-date", "consumption": 15, "read": 25}, + ] + await coordinator._async_update_data() + + assert ( + "Could not parse read_at time also-invalid-date, skipping reading" + in caplog.text + ) diff --git a/tests/components/anglian_water/test_sensor.py b/tests/components/anglian_water/test_sensor.py index d9c0b3446da..dbe49bba6fb 100644 --- a/tests/components/anglian_water/test_sensor.py +++ b/tests/components/anglian_water/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion +from homeassistant.components.recorder import Recorder from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -14,6 +15,7 @@ from tests.common import MockConfigEntry, snapshot_platform async def test_sensor( + recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_anglian_water_client: AsyncMock, diff --git a/tests/components/anova/test_config_flow.py b/tests/components/anova/test_config_flow.py index 3b2afaa49c0..7a93c6ff0cc 100644 --- a/tests/components/anova/test_config_flow.py +++ b/tests/components/anova/test_config_flow.py @@ -23,7 +23,7 @@ async def test_flow_user(hass: HomeAssistant, anova_api: AnovaApi) -> None: result["flow_id"], user_input=CONF_INPUT, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_USERNAME: "sample@gmail.com", CONF_PASSWORD: "sample", diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 80a9aa7105f..d9aff9df21f 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -263,7 +263,7 @@ async def test_subentry_web_search_user_location( "recommended": False, }, ) - assert options["type"] == FlowResultType.FORM + assert options["type"] is FlowResultType.FORM assert options["step_id"] == "advanced" # Configure advanced step @@ -274,7 +274,7 @@ async def test_subentry_web_search_user_location( "chat_model": "claude-sonnet-4-5", }, ) - assert options["type"] == FlowResultType.FORM + assert options["type"] is FlowResultType.FORM assert options["step_id"] == "model" hass.config.country = "US" @@ -354,7 +354,7 @@ async def test_model_list( "recommended": False, }, ) - assert options["type"] == FlowResultType.FORM + assert options["type"] is FlowResultType.FORM assert options["step_id"] == "advanced" assert options["data_schema"].schema["chat_model"].config["options"] == [ { @@ -429,7 +429,7 @@ async def test_model_list_error( "recommended": False, }, ) - assert options["type"] == FlowResultType.FORM + assert options["type"] is FlowResultType.FORM assert options["step_id"] == "advanced" assert options["data_schema"].schema["chat_model"].config["options"] == [] @@ -634,7 +634,7 @@ async def test_subentry_options_switching( assert subentry_flow["step_id"] == "init" for step_options in new_options: - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM assert not subentry_flow["errors"] # Test that current options are showed as suggested values: diff --git a/tests/components/azure_data_explorer/conftest.py b/tests/components/azure_data_explorer/conftest.py index f8915a12ce1..ddf71493e5b 100644 --- a/tests/components/azure_data_explorer/conftest.py +++ b/tests/components/azure_data_explorer/conftest.py @@ -72,7 +72,7 @@ async def _entry(hass: HomeAssistant, filter_schema: dict[str, Any], entry) -> N assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_FILTER: filter_schema}} ) - assert entry.state == ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED # Clear the component_loaded event from the queue. async_fire_time_changed( @@ -87,7 +87,7 @@ async def mock_entry_with_one_event( hass: HomeAssistant, entry_managed ) -> MockConfigEntry: """Use the entry and add a single test event to the queue.""" - assert entry_managed.state == ConfigEntryState.LOADED + assert entry_managed.state is ConfigEntryState.LOADED hass.states.async_set("sensor.test", STATE_ON) return entry_managed diff --git a/tests/components/azure_data_explorer/test_init.py b/tests/components/azure_data_explorer/test_init.py index 10633154efd..2d1d1fcfc2c 100644 --- a/tests/components/azure_data_explorer/test_init.py +++ b/tests/components/azure_data_explorer/test_init.py @@ -106,10 +106,10 @@ async def test_unload_entry( this verifies that the unload, calls async_stop, which calls async_send and shuts down the hub. """ - assert entry_managed.state == ConfigEntryState.LOADED + assert entry_managed.state is ConfigEntryState.LOADED assert await hass.config_entries.async_unload(entry_managed.entry_id) mock_managed_streaming.assert_not_called() - assert entry_managed.state == ConfigEntryState.NOT_LOADED + assert entry_managed.state is ConfigEntryState.NOT_LOADED @pytest.mark.freeze_time("2024-01-01 00:00:00") @@ -261,4 +261,4 @@ async def test_connection( entry.add_to_hass(hass) mock_execute_query.side_effect = sideeffect await hass.config_entries.async_setup(entry.entry_id) - assert entry.state == ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/backblaze_b2/test_init.py b/tests/components/backblaze_b2/test_init.py index e687b9ec2be..53336438147 100644 --- a/tests/components/backblaze_b2/test_init.py +++ b/tests/components/backblaze_b2/test_init.py @@ -22,12 +22,12 @@ async def test_load_unload_config_entry( """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.state == ConfigEntryState.NOT_LOADED # type: ignore[comparison-overlap] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED # type: ignore[comparison-overlap] async def test_setup_entry_invalid_auth( diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index e886d18ad72..e0726778b4a 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -180,6 +180,14 @@ TEST_PLAYBACK_METADATA = PlaybackContentMetadata( track=1, source_internal_id="123", ) +TEST_PLAYBACK_METADATA_VIDEO = PlaybackContentMetadata( + encoding="unknown", + organization="HDMI A", + title="HDMI A", + source_internal_id="hdmi_1", + output_channel_processing="TrueImage", + output_Channels="5.0.2", +) TEST_PLAYBACK_ERROR = PlaybackError(error="Test error") TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123) TEST_PLAYBACK_STATE_PAUSED = RenderingState(value="paused") diff --git a/tests/components/bang_olufsen/test_init.py b/tests/components/bang_olufsen/test_init.py index c8e4c05f9ab..9c9412a5627 100644 --- a/tests/components/bang_olufsen/test_init.py +++ b/tests/components/bang_olufsen/test_init.py @@ -22,13 +22,13 @@ async def test_setup_entry( ) -> None: """Test async_setup_entry.""" - assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED # Load entry mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED # Check that the device has been registered properly device = device_registry.async_get_device( @@ -57,13 +57,13 @@ async def test_setup_entry_failed( "", (ServerTimeoutError(), TimeoutError()) ) - assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED # Load entry mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY # Ensure that the connection has been checked, API client correctly closed # and WebSocket connection has not been initialized @@ -80,12 +80,12 @@ async def test_unload_entry( """Test unload_entry.""" # Load entry - assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED assert hasattr(mock_config_entry, "runtime_data") # Unload entry @@ -97,4 +97,4 @@ async def test_unload_entry( # Ensure that the entry is not loaded and has been removed from hass assert not hasattr(mock_config_entry, "runtime_data") - assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 38593922e30..729be234735 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -100,6 +100,7 @@ from .const import ( TEST_OVERLAY_OFFSET_VOLUME_TTS, TEST_PLAYBACK_ERROR, TEST_PLAYBACK_METADATA, + TEST_PLAYBACK_METADATA_VIDEO, TEST_PLAYBACK_PROGRESS, TEST_PLAYBACK_STATE_PAUSED, TEST_PLAYBACK_STATE_PLAYING, @@ -433,6 +434,36 @@ async def test_async_update_source_change( assert (ATTR_MEDIA_CONTENT_ID in states.attributes) == content_id_available +async def test_async_update_source_change_video( + hass: HomeAssistant, + integration: None, + mock_mozart_client: AsyncMock, +) -> None: + """Test _async_update_source_change with a video source.""" + playback_metadata_callback = ( + mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] + ) + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert ATTR_INPUT_SOURCE not in states.attributes + assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + + # Simulate metadata and source change + playback_metadata_callback(TEST_PLAYBACK_METADATA_VIDEO) + source_change_callback(Source(id="tv", name="TV")) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_INPUT_SOURCE] == TEST_PLAYBACK_METADATA_VIDEO.title + assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == BeoMediaType.TV + assert ( + states.attributes[ATTR_MEDIA_CONTENT_ID] + == TEST_PLAYBACK_METADATA_VIDEO.source_internal_id + ) + + async def test_async_turn_off( hass: HomeAssistant, integration: None, @@ -819,7 +850,7 @@ async def test_async_select_source( audio_source_call: int, video_source_call: int, ) -> None: - """Test async_select_source with an invalid source.""" + """Test async_select_source with an invalid source and valid audio and video sources.""" with expected_result: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, diff --git a/tests/components/blackbird/conftest.py b/tests/components/blackbird/conftest.py new file mode 100644 index 00000000000..40f61dec177 --- /dev/null +++ b/tests/components/blackbird/conftest.py @@ -0,0 +1,3 @@ +"""Fixtures for component.""" + +collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py index 7c46d13437b..1a7f4f6f2cd 100644 --- a/tests/components/blink/conftest.py +++ b/tests/components/blink/conftest.py @@ -85,7 +85,7 @@ def mock_config_fixture(): data={ CONF_USERNAME: "test_user", CONF_PASSWORD: "Password", - "device_id": "Home Assistant", + "hardware_id": "Home Assistant", "uid": "BlinkCamera_e1233333e2-0909-09cd-777a-123456789012", "token": "A_token", "unique_id": "an_email@email.com", @@ -95,5 +95,5 @@ def mock_config_fixture(): "account_id": 654321, }, entry_id=str(uuid4()), - version=3, + version=4, ) diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index 54df2b48cdb..bf2d38ac473 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -28,7 +28,7 @@ 'data': dict({ 'account_id': 654321, 'client_id': 123456, - 'device_id': 'Home Assistant', + 'hardware_id': 'Home Assistant', 'host': 'u034.immedia-semi.com', 'password': '**REDACTED**', 'region_id': 'u034', @@ -52,7 +52,7 @@ ]), 'title': 'Mock Title', 'unique_id': None, - 'version': 3, + 'version': 4, }), }) # --- diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py index a4629a9b461..5c30e575b5e 100644 --- a/tests/components/blink/test_init.py +++ b/tests/components/blink/test_init.py @@ -113,3 +113,32 @@ async def test_migrate( await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_v3_to_v4( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 3 to 4 (device_id to hardware_id).""" + mock_config_entry.add_to_hass(hass) + + # Set up v3 config entry with device_id + data = {**mock_config_entry.data} + data.pop("hardware_id", None) + data["device_id"] = "Home Assistant" + hass.config_entries.async_update_entry( + mock_config_entry, + version=3, + data=data, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 4 + assert "hardware_id" in entry.data + assert "device_id" not in entry.data + assert entry.data["hardware_id"] == "Home Assistant" diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index 563a8392dc8..b3a6bdc5ab7 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -12,11 +12,11 @@ from bluecurrent_api.exceptions import ( import pytest from voluptuous import MultipleInvalid -from homeassistant.components.blue_current import ( +from homeassistant.components.blue_current import async_setup_entry +from homeassistant.components.blue_current.const import ( CHARGING_CARD_ID, DOMAIN, SERVICE_START_CHARGE_SESSION, - async_setup_entry, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, Platform diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 20468d23dc9..2641f6b6dcd 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -45,7 +45,7 @@ def disable_bluetooth_auto_recovery(): def mock_operating_system_85(): """Mock running Home Assistant Operating system 8.5.""" with ( - patch("homeassistant.components.hassio.is_hassio", return_value=True), + patch("homeassistant.helpers.hassio.is_hassio", return_value=True), patch( "homeassistant.components.hassio.get_os_info", return_value={ @@ -67,7 +67,7 @@ def mock_operating_system_85(): def mock_operating_system_90(): """Mock running Home Assistant Operating system 9.0.""" with ( - patch("homeassistant.components.hassio.is_hassio", return_value=True), + patch("homeassistant.helpers.hassio.is_hassio", return_value=True), patch( "homeassistant.components.hassio.get_os_info", return_value={ diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 60ae68755ff..92c2b34b907 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -12,11 +12,13 @@ from bring_api import ( from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.bring import async_setup_entry from homeassistant.components.bring.const import DOMAIN -from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .conftest import UUID @@ -52,11 +54,11 @@ async def test_load_unload( @pytest.mark.parametrize( - ("exception", "status"), + ("exception", "status", "reauth_triggered"), [ - (BringRequestException, ConfigEntryState.SETUP_RETRY), - (BringAuthException, ConfigEntryState.SETUP_ERROR), - (BringParseException, ConfigEntryState.SETUP_RETRY), + (BringRequestException, ConfigEntryState.SETUP_RETRY, False), + (BringAuthException, ConfigEntryState.SETUP_ERROR, True), + (BringParseException, ConfigEntryState.SETUP_RETRY, False), ], ) async def test_init_failure( @@ -64,6 +66,7 @@ async def test_init_failure( mock_bring_client: AsyncMock, status: ConfigEntryState, exception: Exception, + reauth_triggered: bool, bring_config_entry: MockConfigEntry, ) -> None: """Test an initialization error on integration load.""" @@ -71,28 +74,14 @@ async def test_init_failure( await setup_integration(hass, bring_config_entry) assert bring_config_entry.state == status - -@pytest.mark.parametrize( - ("exception", "expected"), - [ - (BringRequestException, ConfigEntryNotReady), - (BringAuthException, ConfigEntryAuthFailed), - (BringParseException, ConfigEntryNotReady), - ], -) -async def test_init_exceptions( - hass: HomeAssistant, - mock_bring_client: AsyncMock, - exception: Exception, - expected: Exception, - bring_config_entry: MockConfigEntry, -) -> None: - """Test an initialization error on integration load.""" - bring_config_entry.add_to_hass(hass) - mock_bring_client.login.side_effect = exception - - with pytest.raises(expected): - await async_setup_entry(hass, bring_config_entry) + assert ( + any( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["context"]["source"] == SOURCE_REAUTH + ) + is reauth_triggered + ) @pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) diff --git a/tests/components/button/test_trigger.py b/tests/components/button/test_trigger.py new file mode 100644 index 00000000000..ab328b1dedc --- /dev/null +++ b/tests/components/button/test_trigger.py @@ -0,0 +1,192 @@ +"""Test button trigger.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import ( + ATTR_LABEL_ID, + CONF_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_buttons(hass: HomeAssistant) -> list[str]: + """Create multiple button entities associated with different targets.""" + return (await target_entities(hass, "button"))["included"] + + +@pytest.mark.parametrize("trigger_key", ["button.pressed"]) +async def test_button_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the button triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("button"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + ( + "button.pressed", + [ + {"included": {"state": None, "attributes": {}}, "count": 0}, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 0, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + ], + ), + ( + "button.pressed", + [ + {"included": {"state": "foo", "attributes": {}}, "count": 0}, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + ], + ), + ( + "button.pressed", + [ + { + "included": {"state": STATE_UNAVAILABLE, "attributes": {}}, + "count": 0, + }, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 0, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + { + "included": {"state": STATE_UNAVAILABLE, "attributes": {}}, + "count": 0, + }, + ], + ), + ( + "button.pressed", + [ + {"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0}, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + {"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0}, + ], + ), + ], +) +async def test_button_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_buttons: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the button state trigger fires when any button state changes to a specific state.""" + other_entity_ids = set(target_buttons) - {entity_id} + + # Set all buttons, including the tested button, to the initial state + for eid in target_buttons: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, None, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check if changing other buttons also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index 5e95bbd6fbe..4375f11fcee 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -148,6 +148,22 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: yield mock_stream_source +@pytest.fixture(name="mock_create_stream") +def mock_create_stream_fixture() -> Generator[Mock]: + """Fixture to mock create_stream and prevent real stream threads.""" + mock_stream = Mock() + mock_stream.add_provider = Mock() + mock_stream.start = AsyncMock() + mock_stream.endpoint_url = Mock(return_value="http://home.assistant/playlist.m3u8") + mock_stream.set_update_callback = Mock() + mock_stream.available = True + with patch( + "homeassistant.components.camera.create_stream", + return_value=mock_stream, + ): + yield mock_stream + + @pytest.fixture async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: """Initialize test WebRTC cameras with native RTC support.""" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 37627b2f63f..ef31bc33fc1 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -346,20 +346,14 @@ async def test_websocket_stream_no_source( @pytest.mark.usefixtures("mock_camera", "mock_stream") async def test_websocket_camera_stream( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_create_stream: Mock ) -> None: """Test camera/stream websocket command.""" await async_setup_component(hass, "camera", {}) - with ( - patch( - "homeassistant.components.camera.Stream.endpoint_url", - return_value="http://home.assistant/playlist.m3u8", - ) as mock_stream_view_url, - patch( - "homeassistant.components.demo.camera.DemoCamera.stream_source", - return_value="http://example.com", - ), + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value="http://example.com", ): # Request playlist through WebSocket client = await hass_ws_client(hass) @@ -369,7 +363,7 @@ async def test_websocket_camera_stream( msg = await client.receive_json() # Assert WebSocket response - assert mock_stream_view_url.called + assert mock_create_stream.endpoint_url.called assert msg["id"] == 6 assert msg["type"] == TYPE_RESULT assert msg["success"] @@ -505,21 +499,18 @@ async def test_play_stream_service_no_source(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_camera", "mock_stream") -async def test_handle_play_stream_service(hass: HomeAssistant) -> None: +async def test_handle_play_stream_service( + hass: HomeAssistant, mock_create_stream: Mock +) -> None: """Test camera play_stream service.""" await async_process_ha_core_config( hass, {"external_url": "https://example.com"}, ) await async_setup_component(hass, "media_player", {}) - with ( - patch( - "homeassistant.components.camera.Stream.endpoint_url", - ) as mock_request_stream, - patch( - "homeassistant.components.demo.camera.DemoCamera.stream_source", - return_value="http://example.com", - ), + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value="http://example.com", ): # Call service await hass.services.async_call( @@ -533,17 +524,14 @@ async def test_handle_play_stream_service(hass: HomeAssistant) -> None: ) # So long as we request the stream, the rest should be covered # by the play_media service tests. - assert mock_request_stream.called + assert mock_create_stream.endpoint_url.called @pytest.mark.usefixtures("mock_stream") -async def test_no_preload_stream(hass: HomeAssistant) -> None: +async def test_no_preload_stream(hass: HomeAssistant, mock_create_stream: Mock) -> None: """Test camera preload preference.""" demo_settings = camera.DynamicStreamSettings() with ( - patch( - "homeassistant.components.camera.Stream.endpoint_url", - ) as mock_request_stream, patch( "homeassistant.components.camera.prefs.CameraPreferences.get_dynamic_stream_settings", return_value=demo_settings, @@ -557,15 +545,14 @@ async def test_no_preload_stream(hass: HomeAssistant) -> None: await async_setup_component(hass, "camera", {DOMAIN: {"platform": "demo"}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert not mock_request_stream.called + assert not mock_create_stream.endpoint_url.called @pytest.mark.usefixtures("mock_stream") -async def test_preload_stream(hass: HomeAssistant) -> None: +async def test_preload_stream(hass: HomeAssistant, mock_create_stream: Mock) -> None: """Test camera preload preference.""" demo_settings = camera.DynamicStreamSettings(preload_stream=True) with ( - patch("homeassistant.components.camera.create_stream") as mock_create_stream, patch( "homeassistant.components.camera.prefs.CameraPreferences.get_dynamic_stream_settings", return_value=demo_settings, @@ -575,14 +562,13 @@ async def test_preload_stream(hass: HomeAssistant) -> None: return_value="http://example.com", ), ): - mock_create_stream.return_value.start = AsyncMock() assert await async_setup_component( hass, "camera", {DOMAIN: {"platform": "demo"}} ) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert mock_create_stream.called + assert mock_create_stream.start.called @pytest.mark.usefixtures("mock_camera") @@ -694,25 +680,16 @@ async def test_state_streaming(hass: HomeAssistant) -> None: assert demo_camera.state == camera.CameraState.STREAMING -@pytest.mark.usefixtures("mock_camera", "mock_stream") +@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_create_stream") async def test_stream_unavailable( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_create_stream: Mock ) -> None: """Camera state.""" await async_setup_component(hass, "camera", {}) - with ( - patch( - "homeassistant.components.camera.Stream.endpoint_url", - return_value="http://home.assistant/playlist.m3u8", - ), - patch( - "homeassistant.components.demo.camera.DemoCamera.stream_source", - return_value="http://example.com", - ), - patch( - "homeassistant.components.camera.Stream.set_update_callback", - ) as mock_update_callback, + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value="http://example.com", ): # Request playlist through WebSocket. We just want to create the stream # but don't care about the result. @@ -721,26 +698,22 @@ async def test_stream_unavailable( {"id": 10, "type": "camera/stream", "entity_id": "camera.demo_camera"} ) await client.receive_json() - assert mock_update_callback.called + assert mock_create_stream.set_update_callback.called # Simulate the stream going unavailable - callback = mock_update_callback.call_args.args[0] - with patch( - "homeassistant.components.camera.Stream.available", new_callable=lambda: False - ): - callback() - await hass.async_block_till_done() + callback = mock_create_stream.set_update_callback.call_args.args[0] + mock_create_stream.available = False + callback() + await hass.async_block_till_done() demo_camera = hass.states.get("camera.demo_camera") assert demo_camera is not None assert demo_camera.state == STATE_UNAVAILABLE # Simulate stream becomes available - with patch( - "homeassistant.components.camera.Stream.available", new_callable=lambda: True - ): - callback() - await hass.async_block_till_done() + mock_create_stream.available = True + callback() + await hass.async_block_till_done() demo_camera = hass.states.get("camera.demo_camera") assert demo_camera is not None diff --git a/tests/components/color_extractor/test_services.py b/tests/components/color_extractor/test_services.py index e46e5843210..619e786712c 100644 --- a/tests/components/color_extractor/test_services.py +++ b/tests/components/color_extractor/test_services.py @@ -9,7 +9,7 @@ import aiohttp import pytest from voluptuous.error import MultipleInvalid -from homeassistant.components.color_extractor import ( +from homeassistant.components.color_extractor.services import ( ATTR_PATH, ATTR_URL, DOMAIN, @@ -270,7 +270,9 @@ async def test_file(hass: HomeAssistant, setup_integration) -> None: assert state.state == STATE_OFF # Mock the file handler read with our 1x1 base64 encoded fixture image - with patch("homeassistant.components.color_extractor._get_file", _get_file_mock): + with patch( + "homeassistant.components.color_extractor.services._get_file", _get_file_mock + ): await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) await hass.async_block_till_done() @@ -305,7 +307,9 @@ async def test_file_denied_dir(hass: HomeAssistant, setup_integration) -> None: assert state.state == STATE_OFF # Mock the file handler read with our 1x1 base64 encoded fixture image - with patch("homeassistant.components.color_extractor._get_file", _get_file_mock): + with patch( + "homeassistant.components.color_extractor.services._get_file", _get_file_mock + ): await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) await hass.async_block_till_done() diff --git a/tests/components/compit/test_config_flow.py b/tests/components/compit/test_config_flow.py index 58f364c62bc..48086f6e107 100644 --- a/tests/components/compit/test_config_flow.py +++ b/tests/components/compit/test_config_flow.py @@ -35,7 +35,7 @@ async def test_async_step_user_success( result["flow_id"], CONFIG_INPUT ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == CONFIG_INPUT[CONF_EMAIL] assert result["data"] == CONFIG_INPUT assert len(mock_setup_entry.mock_calls) == 1 @@ -70,14 +70,14 @@ async def test_async_step_user_failed_auth( result["flow_id"], CONFIG_INPUT ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": expected_error} # Test success after error is cleared result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == CONFIG_INPUT[CONF_EMAIL] assert result["data"] == CONFIG_INPUT assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 2421a7d10c5..92458aff100 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -172,6 +172,39 @@ async def test_create_area( } assert len(area_registry.areas) == 2 + # Create area with invalid aliases + await client.send_json_auto_id( + { + "aliases": [" alias_1 ", "", " "], + "floor_id": "first_floor", + "icon": "mdi:garage", + "labels": ["label_1", "label_2"], + "name": "mock 3", + "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", + "type": "config/area_registry/create", + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "aliases": unordered(["alias_1"]), + "area_id": ANY, + "floor_id": "first_floor", + "icon": "mdi:garage", + "labels": unordered(["label_1", "label_2"]), + "name": "mock 3", + "picture": "/image/example.png", + "created_at": utcnow().timestamp(), + "modified_at": utcnow().timestamp(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", + } + assert len(area_registry.areas) == 3 + async def test_create_area_with_name_already_in_use( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry @@ -304,6 +337,40 @@ async def test_update_area( } assert len(area_registry.areas) == 1 + modified_at = datetime.fromisoformat("2024-07-16T13:55:00.900075+00:00") + freezer.move_to(modified_at) + + await client.send_json_auto_id( + { + "type": "config/area_registry/update", + "aliases": ["alias_1", "", " ", " alias_2 "], + "area_id": area.id, + "floor_id": None, + "humidity_entity_id": None, + "icon": None, + "labels": [], + "picture": None, + "temperature_entity_id": None, + } + ) + + msg = await client.receive_json() + + assert msg["result"] == { + "aliases": unordered(["alias_1", "alias_2"]), + "area_id": area.id, + "floor_id": None, + "icon": None, + "labels": [], + "name": "mock 2", + "picture": None, + "temperature_entity_id": None, + "humidity_entity_id": None, + "created_at": created_at.timestamp(), + "modified_at": modified_at.timestamp(), + } + assert len(area_registry.areas) == 1 + async def test_update_area_with_same_name( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index f43167f1121..1587b2402fd 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1028,7 +1028,7 @@ async def test_get_progress_subscribe_create_entry(hass: HomeAssistant) -> None: "test", context={"source": core_ce.SOURCE_IMPORT}, data={} ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(subscription_mock.mock_calls) == 0 diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 15a7ac70ac7..b6daf7027a6 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -887,6 +887,49 @@ async def test_update_entity( }, } + # Add illegal terms to aliases + await client.send_json_auto_id( + { + "type": "config/entity_registry/update", + "entity_id": "test_domain.world", + "aliases": ["alias_1", "alias_2", "", " alias_3 ", " "], + } + ) + + msg = await client.receive_json() + assert msg["success"] + + assert msg["result"] == { + "entity_entry": { + "aliases": unordered(["alias_1", "alias_2", "alias_3"]), + "area_id": "mock-area-id", + "capabilities": None, + "categories": {"scope1": "id", "scope3": "other_id"}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": created.timestamp(), + "device_class": "custom_device_class", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test_domain.world", + "has_entity_name": False, + "hidden_by": "user", # We exchange strings over the WS API, not enums + "icon": "icon:after update", + "id": ANY, + "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), + "name": "after update", + "options": {"sensor": {"unit_of_measurement": "beard_second"}}, + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "translation_key": None, + "unique_id": "1234", + }, + } + async def test_update_entity_require_restart( hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory diff --git a/tests/components/config/test_floor_registry.py b/tests/components/config/test_floor_registry.py index 3b0770aa976..6e563e72669 100644 --- a/tests/components/config/test_floor_registry.py +++ b/tests/components/config/test_floor_registry.py @@ -122,6 +122,30 @@ async def test_create_floor( "level": 2, } + # Floor with invalid aliases + await client.send_json_auto_id( + { + "name": "Third floor", + "type": "config/floor_registry/create", + "aliases": ["", " "], + "icon": "mdi:home-floor-2", + "level": 3, + } + ) + + msg = await client.receive_json() + + assert len(floor_registry.floors) == 3 + assert msg["result"] == { + "aliases": [], + "created_at": utcnow().timestamp(), + "icon": "mdi:home-floor-2", + "floor_id": "third_floor", + "modified_at": utcnow().timestamp(), + "name": "Third floor", + "level": 3, + } + async def test_create_floor_with_name_already_in_use( client: MockHAClientWebSocket, @@ -249,6 +273,60 @@ async def test_update_floor( "level": None, } + # Add invalid aliases + modified_at = datetime.fromisoformat("2024-07-16T13:55:00.900075+00:00") + freezer.move_to(modified_at) + await client.send_json_auto_id( + { + "floor_id": floor.floor_id, + "name": "First floor", + "aliases": ["top floor", "attic", "", " "], + "icon": None, + "level": None, + "type": "config/floor_registry/update", + } + ) + + msg = await client.receive_json() + + assert len(floor_registry.floors) == 1 + assert msg["result"] == { + "aliases": unordered(["top floor", "attic"]), + "created_at": created_at.timestamp(), + "icon": None, + "floor_id": floor.floor_id, + "modified_at": modified_at.timestamp(), + "name": "First floor", + "level": None, + } + + # Add alias with trailing and leading whitespaces + modified_at = datetime.fromisoformat("2024-07-16T13:55:00.900075+00:00") + freezer.move_to(modified_at) + await client.send_json_auto_id( + { + "floor_id": floor.floor_id, + "name": "First floor", + "aliases": ["top floor", "attic", "solaio "], + "icon": None, + "level": None, + "type": "config/floor_registry/update", + } + ) + + msg = await client.receive_json() + + assert len(floor_registry.floors) == 1 + assert msg["result"] == { + "aliases": unordered(["top floor", "attic", "solaio"]), + "created_at": created_at.timestamp(), + "icon": None, + "floor_id": floor.floor_id, + "modified_at": modified_at.timestamp(), + "name": "First floor", + "level": None, + } + async def test_update_with_name_already_in_use( client: MockHAClientWebSocket, diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 069442517a0..7e134422441 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -47,7 +47,7 @@ async def test_flow_user_success( user_input=MOCK_DATA_USER_STEP, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "language" result = await hass.config_entries.flow.async_configure( @@ -97,7 +97,7 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( user_input=MOCK_DATA_USER_STEP, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "language" result = await hass.config_entries.flow.async_configure( @@ -137,7 +137,7 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_2( user_input=MOCK_DATA_USER_STEP, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "language" result = await hass.config_entries.flow.async_configure( @@ -269,7 +269,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "language" result = await hass.config_entries.flow.async_configure( @@ -318,7 +318,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "language" result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/coolmaster/test_config_flow.py b/tests/components/coolmaster/test_config_flow.py index 83a074815b5..4439c2b5a33 100644 --- a/tests/components/coolmaster/test_config_flow.py +++ b/tests/components/coolmaster/test_config_flow.py @@ -9,18 +9,34 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -def _flow_data(): +def _flow_data(advanced=False): options = {"host": "1.1.1.1"} for mode in AVAILABLE_MODES: options[mode] = True options["swing_support"] = False + if advanced: + options["send_wakeup_prompt"] = True return options -async def test_form(hass: HomeAssistant) -> None: +async def test_form_non_advanced(hass: HomeAssistant) -> None: + """Test we get the form in non-advanced mode.""" + await form_base(hass, advanced=False) + + +async def test_form_advanced(hass: HomeAssistant) -> None: + """Test we get the form in advanced mode.""" + await form_base(hass, advanced=True) + + +async def form_base(hass: HomeAssistant, advanced: bool) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + "show_advanced_options": advanced, + }, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -36,18 +52,22 @@ async def test_form(hass: HomeAssistant) -> None: ) as mock_setup_entry, ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], _flow_data() + result["flow_id"], _flow_data(advanced) ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "1.1.1.1" - assert result2["data"] == { + _expected_data = { "host": "1.1.1.1", "port": 10102, "supported_modes": AVAILABLE_MODES, "swing_support": False, + "send_wakeup_prompt": False, } + if advanced: + _expected_data["send_wakeup_prompt"] = True + assert result2["data"] == _expected_data assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py index 1d181774fbe..6bb8d88c68e 100644 --- a/tests/components/datadog/test_config_flow.py +++ b/tests/components/datadog/test_config_flow.py @@ -24,13 +24,13 @@ async def test_user_flow_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( datadog.DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_CONFIG ) assert result2["title"] == f"Datadog {MOCK_CONFIG['host']}" - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["data"] == MOCK_DATA assert result2["options"] == MOCK_OPTIONS @@ -48,7 +48,7 @@ async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> Non result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_CONFIG ) - assert result2["type"] == FlowResultType.FORM + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} with patch( @@ -57,7 +57,7 @@ async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> Non result3 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_CONFIG ) - assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["data"] == MOCK_DATA assert result3["options"] == MOCK_OPTIONS @@ -104,13 +104,13 @@ async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: side_effect=OSError("connection failed"), ): result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=MOCK_OPTIONS ) - assert result2["type"] == FlowResultType.FORM + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} with patch( @@ -119,7 +119,7 @@ async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: result3 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=MOCK_OPTIONS ) - assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["data"] == MOCK_OPTIONS @@ -141,7 +141,7 @@ async def test_import_flow( data=MOCK_CONFIG, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == MOCK_DATA assert result["options"] == MOCK_OPTIONS @@ -200,11 +200,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: side_effect=OSError, ): result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=new_options ) - assert result2["type"] == FlowResultType.FORM + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} # ValueError Case @@ -213,11 +213,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: side_effect=ValueError, ): result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=new_options ) - assert result2["type"] == FlowResultType.FORM + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} # Success Case @@ -231,7 +231,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input=new_options ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == new_options mock_instance.increment.assert_called_once_with("connection_test") @@ -253,5 +253,5 @@ async def test_import_flow_abort_already_configured_service( data=MOCK_CONFIG, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/device_tracker/test_trigger.py b/tests/components/device_tracker/test_trigger.py new file mode 100644 index 00000000000..468de2a38dc --- /dev/null +++ b/tests/components/device_tracker/test_trigger.py @@ -0,0 +1,231 @@ +"""Test device_tracker trigger.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import ( + ATTR_LABEL_ID, + CONF_ENTITY_ID, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + +STATE_WORK_ZONE = "work" + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_device_trackers(hass: HomeAssistant) -> list[str]: + """Create multiple device_trackers entities associated with different targets.""" + return (await target_entities(hass, "device_tracker"))["included"] + + +@pytest.mark.parametrize( + "trigger_key", + ["device_tracker.entered_home", "device_tracker.left_home"], +) +async def test_device_tracker_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the device_tracker triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("device_tracker"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="device_tracker.entered_home", + target_states=[STATE_HOME], + other_states=[STATE_NOT_HOME, STATE_WORK_ZONE], + ), + *parametrize_trigger_states( + trigger="device_tracker.left_home", + target_states=[STATE_NOT_HOME, STATE_WORK_ZONE], + other_states=[STATE_HOME], + ), + ], +) +async def test_device_tracker_home_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_device_trackers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the device_tracker home triggers when any device_tracker changes to a specific state.""" + other_entity_ids = set(target_device_trackers) - {entity_id} + + # Set all device_trackers, including the tested device_tracker, to the initial state + for eid in target_device_trackers: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check that changing other device_trackers also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("device_tracker"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="device_tracker.entered_home", + target_states=[STATE_HOME], + other_states=[STATE_NOT_HOME, STATE_WORK_ZONE], + ), + *parametrize_trigger_states( + trigger="device_tracker.left_home", + target_states=[STATE_NOT_HOME, STATE_WORK_ZONE], + other_states=[STATE_HOME], + ), + ], +) +async def test_device_tracker_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_device_trackers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the device_tracker home triggers when the first device_tracker changes to a specific state.""" + other_entity_ids = set(target_device_trackers) - {entity_id} + + # Set all device_trackers, including the tested device_tracker, to the initial state + for eid in target_device_trackers: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other device_trackers should not cause the trigger to fire again + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("device_tracker"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="device_tracker.entered_home", + target_states=[STATE_HOME], + other_states=[STATE_NOT_HOME, STATE_WORK_ZONE], + ), + *parametrize_trigger_states( + trigger="device_tracker.left_home", + target_states=[STATE_NOT_HOME, STATE_WORK_ZONE], + other_states=[STATE_HOME], + ), + ], +) +async def test_device_tracker_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_device_trackers: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the device_tracker home triggers when the last device_tracker changes to a specific state.""" + other_entity_ids = set(target_device_trackers) - {entity_id} + + # Set all device_trackers, including the tested device_tracker, to the initial state + for eid in target_device_trackers: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() diff --git a/tests/components/ekeybionyx/conftest.py b/tests/components/ekeybionyx/conftest.py index b6fc9be1572..3273f9c5663 100644 --- a/tests/components/ekeybionyx/conftest.py +++ b/tests/components/ekeybionyx/conftest.py @@ -3,13 +3,16 @@ from http import HTTPStatus from unittest.mock import patch +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.ekeybionyx.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator def dummy_systems( @@ -136,7 +139,8 @@ def mock_add_webhook( def mock_webhook_id(): """Mock webhook_id.""" with patch( - "homeassistant.components.webhook.async_generate_id", return_value="1234567890" + "homeassistant.components.ekeybionyx.config_flow.webhook_generate_id", + return_value="1234567890", ): yield @@ -171,3 +175,26 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: version=1, minor_version=1, ) + + +@pytest.fixture +async def load_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.fixture +async def webhook_test_env( + hass: HomeAssistant, + load_config_entry: None, + hass_client_no_auth: ClientSessionGenerator, +) -> TestClient: + """Provide a ready HTTP/webhook stack and return client.""" + assert await async_setup_component(hass, "http", {"http": {}}) + assert await async_setup_component(hass, "webhook", {}) + return await hass_client_no_auth() diff --git a/tests/components/ekeybionyx/test_event.py b/tests/components/ekeybionyx/test_event.py new file mode 100644 index 00000000000..ec6c7c1c0c5 --- /dev/null +++ b/tests/components/ekeybionyx/test_event.py @@ -0,0 +1,150 @@ +"""Test the ekey bionyx event platform.""" + +from http import HTTPStatus + +from aiohttp.test_utils import TestClient + +from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_event_entity_setup( + hass: HomeAssistant, load_config_entry: None, config_entry: MockConfigEntry +) -> None: + """Test event entity is set up correctly.""" + # Check that entity was created + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_event_types_attribute( + hass: HomeAssistant, load_config_entry: None, config_entry: MockConfigEntry +) -> None: + """Test event entity has correct event_types attribute.""" + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + + # Check event_types attribute + event_types = state.attributes.get(ATTR_EVENT_TYPES) + assert event_types is not None + assert event_types == ["event happened"] + + +async def test_config_entry_unload( + hass: HomeAssistant, + load_config_entry: None, + config_entry: MockConfigEntry, +) -> None: + """Test config entry can be unloaded.""" + assert config_entry.state is ConfigEntryState.LOADED + + # Verify entity exists + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNKNOWN + + # Unload config entry + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + # Entity should become unavailable + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_webhook_handler_triggers_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webhook_test_env: TestClient, +) -> None: + """Test webhook handler triggers event via HTTP request.""" + webhook_data = config_entry.data["webhooks"][0] + + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNKNOWN + + response = await webhook_test_env.post( + f"/api/webhook/{webhook_data['webhook_id']}", + json={"auth": webhook_data["auth"]}, + ) + assert response.status == HTTPStatus.OK + + await hass.async_block_till_done() + + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state not in (STATE_UNKNOWN, None) + assert state.attributes.get(ATTR_EVENT_TYPE) == "event happened" + + +async def test_webhook_handler_rejects_invalid_auth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webhook_test_env: TestClient, +) -> None: + """Test webhook handler ignores requests with invalid auth.""" + webhook_data = config_entry.data["webhooks"][0] + + response = await webhook_test_env.post( + f"/api/webhook/{webhook_data['webhook_id']}", + json={"auth": "invalid"}, + ) + assert response.status == HTTPStatus.UNAUTHORIZED + + await hass.async_block_till_done() + + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_webhook_handler_missing_auth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webhook_test_env: TestClient, +) -> None: + """Test webhook handler requires the auth field.""" + webhook_data = config_entry.data["webhooks"][0] + + response = await webhook_test_env.post( + f"/api/webhook/{webhook_data['webhook_id']}", + json={"not_auth": "value"}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + + await hass.async_block_till_done() + + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_webhook_handler_invalid_json( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webhook_test_env: TestClient, +) -> None: + """Test webhook handler rejects invalid JSON payloads.""" + webhook_data = config_entry.data["webhooks"][0] + + response = await webhook_test_env.post( + f"/api/webhook/{webhook_data['webhook_id']}", + data="not json", + headers={"Content-Type": "application/json"}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + + await hass.async_block_till_done() + + state = hass.states.get(f"event.{config_entry.data['webhooks'][0]['name']}") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/elevenlabs/test_setup.py b/tests/components/elevenlabs/test_setup.py index 18b90ca3561..dd16a531d82 100644 --- a/tests/components/elevenlabs/test_setup.py +++ b/tests/components/elevenlabs/test_setup.py @@ -18,10 +18,10 @@ async def test_setup( """Test entry setup without any exceptions.""" mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) - assert mock_entry.state == ConfigEntryState.LOADED + assert mock_entry.state is ConfigEntryState.LOADED # Unload await hass.config_entries.async_unload(mock_entry.entry_id) - assert mock_entry.state == ConfigEntryState.NOT_LOADED + assert mock_entry.state is ConfigEntryState.NOT_LOADED async def test_setup_connect_error( @@ -33,4 +33,4 @@ async def test_setup_connect_error( mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) # Ensure is not ready - assert mock_entry.state == ConfigEntryState.SETUP_RETRY + assert mock_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 0eb6ae79d91..d50c9720e6d 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -2306,7 +2306,7 @@ async def test_reconfigure_preserves_existing_config_entry_fields( result = await config_entry.start_reconfigure_flow(hass) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) diff --git a/tests/components/energyid/test_config_flow.py b/tests/components/energyid/test_config_flow.py index 52daa6e974e..694f3ccb571 100644 --- a/tests/components/energyid/test_config_flow.py +++ b/tests/components/energyid/test_config_flow.py @@ -470,7 +470,7 @@ async def test_config_flow_reauth_success( ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT + assert result2["type"] is FlowResultType.ABORT # Entry should be updated updated_entry = hass.config_entries.async_get_entry(entry.entry_id) assert updated_entry.data[CONF_PROVISIONING_KEY] == "new_key" diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index 1445048f0c1..1146082b8cd 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -24,7 +24,7 @@ async def user_flow(hass: HomeAssistant) -> str: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None return result["flow_id"] @@ -79,7 +79,7 @@ async def test_form_user_errors( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == SOURCE_USER assert result["errors"] == {"base": error_value} diff --git a/tests/components/epic_games_store/test_config_flow.py b/tests/components/epic_games_store/test_config_flow.py index 83e9cf9e99e..b719e5455e1 100644 --- a/tests/components/epic_games_store/test_config_flow.py +++ b/tests/components/epic_games_store/test_config_flow.py @@ -39,7 +39,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None with patch( @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["result"].unique_id == f"freegames-{MOCK_LANGUAGE}-{MOCK_COUNTRY}" assert ( result2["title"] @@ -85,7 +85,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == FlowResultType.FORM + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} @@ -107,7 +107,7 @@ async def test_form_cannot_connect_wrong_param(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == FlowResultType.FORM + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} @@ -130,7 +130,7 @@ async def test_form_service_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["result"].unique_id == f"freegames-{MOCK_LANGUAGE}-{MOCK_COUNTRY}" assert ( result2["title"] diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index a2bb7d7e728..750b3f9af00 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -344,7 +344,7 @@ async def test_user_resolve_error(hass: HomeAssistant, mock_client: APIClient) - user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test" assert result2["data"] == { CONF_HOST: "127.0.0.1", @@ -434,7 +434,7 @@ async def test_user_connection_error( user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test" assert result2["data"] == { CONF_HOST: "127.0.0.1", @@ -512,7 +512,7 @@ async def test_user_invalid_password( result["flow_id"], user_input={CONF_PASSWORD: "good"} ) - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test" assert result2["data"] == { CONF_HOST: "127.0.0.1", @@ -765,7 +765,7 @@ async def test_login_connection_error( result["flow_id"], user_input={CONF_PASSWORD: "good"} ) - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test" assert result2["data"] == { CONF_HOST: "127.0.0.1", diff --git a/tests/components/firefly_iii/test_config_flow.py b/tests/components/firefly_iii/test_config_flow.py index 3da91be316f..54669457c8b 100644 --- a/tests/components/firefly_iii/test_config_flow.py +++ b/tests/components/firefly_iii/test_config_flow.py @@ -97,7 +97,7 @@ async def test_form_exceptions( user_input=MOCK_USER_SETUP, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": reason} mock_firefly_client.get_about.side_effect = None diff --git a/tests/components/folder_watcher/test_config_flow.py b/tests/components/folder_watcher/test_config_flow.py index 3b41b5724fc..c92a542bdb7 100644 --- a/tests/components/folder_watcher/test_config_flow.py +++ b/tests/components/folder_watcher/test_config_flow.py @@ -26,7 +26,7 @@ async def test_form(hass: HomeAssistant, tmp_path: Path) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -34,7 +34,7 @@ async def test_form(hass: HomeAssistant, tmp_path: Path) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Folder Watcher {path}" assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]} @@ -51,7 +51,7 @@ async def test_form_not_allowed_path(hass: HomeAssistant, tmp_path: Path) -> Non {CONF_FOLDER: path}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "not_allowed_dir"} hass.config.allowlist_external_dirs = {tmp_path} @@ -62,7 +62,7 @@ async def test_form_not_allowed_path(hass: HomeAssistant, tmp_path: Path) -> Non ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Folder Watcher {path}" assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]} @@ -79,7 +79,7 @@ async def test_form_not_directory(hass: HomeAssistant, tmp_path: Path) -> None: {CONF_FOLDER: "not_a_directory"}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "not_dir"} hass.config.allowlist_external_dirs = {path} @@ -90,7 +90,7 @@ async def test_form_not_directory(hass: HomeAssistant, tmp_path: Path) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Folder Watcher {path}" assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]} @@ -109,7 +109,7 @@ async def test_form_not_readable_dir(hass: HomeAssistant, tmp_path: Path) -> Non ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "not_readable_dir"} hass.config.allowlist_external_dirs = {path} @@ -120,7 +120,7 @@ async def test_form_not_readable_dir(hass: HomeAssistant, tmp_path: Path) -> Non ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Folder Watcher {path}" assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]} @@ -146,5 +146,5 @@ async def test_form_already_configured(hass: HomeAssistant, tmp_path: Path) -> N {CONF_FOLDER: path}, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 5792ccf85b1..d7c8c5b2e4a 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -25,6 +25,7 @@ async def setup_config_entry( device: Mock | None = None, fritz: Mock | None = None, template: Mock | None = None, + trigger: Mock | None = None, ) -> MockConfigEntry: """Do setup of a MockConfigEntry.""" entry = MockConfigEntry( @@ -39,6 +40,9 @@ async def setup_config_entry( if template is not None and fritz is not None: fritz().get_templates.return_value = [template] + if trigger is not None and fritz is not None: + fritz().get_triggers.return_value = [trigger] + await hass.config_entries.async_setup(entry.entry_id) if device is not None: await hass.async_block_till_done() @@ -46,7 +50,10 @@ async def setup_config_entry( def set_devices( - fritz: Mock, devices: list[Mock] | None = None, templates: list[Mock] | None = None + fritz: Mock, + devices: list[Mock] | None = None, + templates: list[Mock] | None = None, + triggers: list[Mock] | None = None, ) -> None: """Set list of devices or templates.""" if devices is not None: @@ -55,6 +62,9 @@ def set_devices( if templates is not None: fritz().get_templates.return_value = templates + if triggers is not None: + fritz().get_triggers.return_value = triggers + class FritzEntityBaseMock(Mock): """base mock of a AVM Fritz!Box binary sensor device.""" @@ -199,3 +209,11 @@ class FritzDeviceCoverUnknownPositionMock(FritzDeviceCoverMock): """Mock of a AVM Fritz!Box cover device with unknown position.""" levelpercentage = None + + +class FritzTriggerMock(FritzEntityBaseMock): + """Mock of a AVM Fritz!Box smarthome trigger.""" + + active = True + ain = "trg1234 56789" + name = "fake_trigger" diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr index b58c37a7619..e9d380cc85a 100644 --- a/tests/components/fritzbox/snapshots/test_switch.ambr +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -47,3 +47,51 @@ 'state': 'on', }) # --- +# name: test_setup[switch.fake_trigger-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_trigger', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_trigger', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'trg1234 56789', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.fake_trigger-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_trigger', + }), + 'context': , + 'entity_id': 'switch.fake_trigger', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 86d1f58239d..ec2ea48f521 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -23,12 +23,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import FritzDeviceSwitchMock, set_devices, setup_config_entry +from . import FritzDeviceSwitchMock, FritzTriggerMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed, snapshot_platform -ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" +SWITCH_ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" +TRIGGER_ENTITY_ID = f"{SWITCH_DOMAIN}.fake_trigger" async def test_setup( @@ -39,50 +40,56 @@ async def test_setup( ) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() + trigger = FritzTriggerMock() + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]): entry = await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, + MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + device=device, + fritz=fritz, + trigger=trigger, ) assert entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: - """Test turn device on.""" +async def test_switch_turn_on(hass: HomeAssistant, fritz: Mock) -> None: + """Test turn switch device on.""" device = FritzDeviceSwitchMock() await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz ) await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True ) assert device.set_switch_state_on.call_count == 1 -async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: - """Test turn device off.""" +async def test_switch_turn_off(hass: HomeAssistant, fritz: Mock) -> None: + """Test turn switch device off.""" device = FritzDeviceSwitchMock() await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz ) await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True ) assert device.set_switch_state_off.call_count == 1 -async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: - """Test toggling while device is locked.""" +async def test_switch_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: + """Test toggling while switch device is locked.""" device = FritzDeviceSwitchMock() device.lock = True await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz ) with pytest.raises( @@ -90,7 +97,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: match="Can't toggle switch while manual switching is disabled for the device", ): await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True ) with pytest.raises( @@ -98,17 +105,23 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: match="Can't toggle switch while manual switching is disabled for the device", ): await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True ) async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSwitchMock() + trigger = FritzTriggerMock() await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, + MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + device=device, + fritz=fritz, + trigger=trigger, ) assert fritz().update_devices.call_count == 1 + assert fritz().update_triggers.call_count == 1 assert fritz().login.call_count == 1 next_update = dt_util.utcnow() + timedelta(seconds=200) @@ -116,6 +129,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None: await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 2 + assert fritz().update_triggers.call_count == 2 assert fritz().login.call_count == 1 @@ -124,7 +138,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceSwitchMock() fritz().update_devices.side_effect = HTTPError("Boom") entry = await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz ) assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 @@ -145,10 +159,10 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No device.energy = 0 device.power = 0 await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(SWITCH_ENTITY_ID) assert state assert state.state == STATE_UNAVAILABLE @@ -156,13 +170,19 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSwitchMock() + trigger = FritzTriggerMock() await setup_config_entry( - hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + hass, + MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + device=device, + fritz=fritz, + trigger=trigger, ) - state = hass.states.get(ENTITY_ID) - assert state + assert hass.states.get(SWITCH_ENTITY_ID) + assert hass.states.get(TRIGGER_ENTITY_ID) + # add new switch device new_device = FritzDeviceSwitchMock() new_device.ain = "7890 1234" new_device.name = "new_switch" @@ -172,5 +192,48 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{SWITCH_DOMAIN}.new_switch") - assert state + assert hass.states.get(f"{SWITCH_DOMAIN}.new_switch") + + # add new trigger + new_trigger = FritzTriggerMock() + new_trigger.ain = "trg7890 1234" + new_trigger.name = "new_trigger" + set_devices(fritz, triggers=[trigger, new_trigger]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(f"{SWITCH_DOMAIN}.new_trigger") + + +async def test_activate_trigger(hass: HomeAssistant, fritz: Mock) -> None: + """Test activating a FRITZ! trigger.""" + trigger = FritzTriggerMock() + await setup_config_entry( + hass, + MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + fritz=fritz, + trigger=trigger, + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TRIGGER_ENTITY_ID}, True + ) + assert fritz().set_trigger_active.call_count == 1 + + +async def test_deactivate_trigger(hass: HomeAssistant, fritz: Mock) -> None: + """Test deactivating a FRITZ! trigger.""" + trigger = FritzTriggerMock() + await setup_config_entry( + hass, + MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + fritz=fritz, + trigger=trigger, + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TRIGGER_ENTITY_ID}, True + ) + assert fritz().set_trigger_inactive.call_count == 1 diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py index 3181e602d59..be039577d63 100644 --- a/tests/components/gardena_bluetooth/test_config_flow.py +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -70,14 +70,14 @@ async def test_user_selection_replaces_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 96cdfe41d0d..85ba34fde09 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -129,13 +129,15 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: "stream_source": "http://janebloggs:letmein2@example.com/stream", "username": "johnbloggs", "password": "letmein123", - "limit_refetch_to_url_change": False, - "authentication": "basic", - "framerate": 2.0, - "verify_ssl": True, "content_type": "image/jpeg", + "advanced": { + "framerate": 2.0, + "verify_ssl": True, + "limit_refetch_to_url_change": False, + "authentication": "basic", + }, }, - version=1, + version=2, ) entry.add_to_hass(hass) return entry diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index d3ef0a39241..14d83fadea7 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -4,7 +4,7 @@ import asyncio from datetime import timedelta from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory @@ -37,7 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import Mock, MockConfigEntry +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -353,10 +353,16 @@ async def test_stream_source( stream_source = await async_get_stream_source(hass, "camera.config_test") assert stream_source == "http://barney:betty@example.com/5a" + # Create a mock stream that doesn't actually try to connect + mock_stream = Mock() + mock_stream.add_provider = Mock() + mock_stream.start = AsyncMock() + mock_stream.endpoint_url = Mock(return_value="http://home.assistant/playlist.m3u8") + with patch( - "homeassistant.components.camera.Stream.endpoint_url", - return_value="http://home.assistant/playlist.m3u8", - ) as mock_stream_url: + "homeassistant.components.camera.create_stream", + return_value=mock_stream, + ): # Request playlist through WebSocket client = await hass_ws_client(hass) @@ -366,7 +372,7 @@ async def test_stream_source( msg = await client.receive_json() # Assert WebSocket response - assert mock_stream_url.call_count == 1 + assert mock_stream.endpoint_url.call_count == 1 assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index bf05676fc67..d599188696b 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator import contextlib +from copy import deepcopy import errno from http import HTTPStatus import os.path @@ -24,6 +25,7 @@ from homeassistant.components.generic.const import ( CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, DOMAIN, + SECTION_ADVANCED, ) from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -48,11 +50,13 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator TESTDATA = { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2", - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_FRAMERATE: 5, - CONF_VERIFY_SSL: False, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 5, + CONF_VERIFY_SSL: False, + }, } TESTDATA_ONLYSTILL = TESTDATA.copy() @@ -61,11 +65,6 @@ TESTDATA_ONLYSTILL.pop(CONF_STREAM_SOURCE) TESTDATA_ONLYSTREAM = TESTDATA.copy() TESTDATA_ONLYSTREAM.pop(CONF_STILL_IMAGE_URL) -TESTDATA_OPTIONS = { - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, - **TESTDATA, -} - @respx.mock @pytest.mark.usefixtures("fakeimg_png") @@ -98,6 +97,10 @@ async def test_form( ) json = await ws_client.receive_json() + # Check stream_url is absolute (required by HLS player for child playlist URLs) + stream_preview_url = json["event"]["attributes"]["stream_url"] + assert stream_preview_url.startswith("http") + client = await hass_client() still_preview_url = json["event"]["attributes"]["still_url"] # Check the preview image works. @@ -114,12 +117,14 @@ async def test_form( assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2", - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5.0, - CONF_VERIFY_SSL: False, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 5.0, + CONF_VERIFY_SSL: False, + }, } # Check that the preview image is disabled after. @@ -150,12 +155,14 @@ async def test_form_only_stillimage( assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5.0, - CONF_VERIFY_SSL: False, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 5.0, + CONF_VERIFY_SSL: False, + }, } assert respx.calls.call_count == 1 @@ -376,8 +383,8 @@ async def test_form_rtsp_mode( mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user enters a stream url.""" - data = TESTDATA.copy() - data[CONF_RTSP_TRANSPORT] = "tcp" + data = deepcopy(TESTDATA) + data[SECTION_ADVANCED][CONF_RTSP_TRANSPORT] = "tcp" data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" result1 = await hass.config_entries.flow.async_configure(user_flow["flow_id"], data) assert result1["type"] is FlowResultType.FORM @@ -390,14 +397,16 @@ async def test_form_rtsp_mode( assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_STREAM_SOURCE: "rtsp://127.0.0.1/testurl/2", - CONF_RTSP_TRANSPORT: "tcp", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5.0, - CONF_VERIFY_SSL: False, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 5.0, + CONF_VERIFY_SSL: False, + CONF_RTSP_TRANSPORT: "tcp", + }, } @@ -423,13 +432,15 @@ async def test_form_only_stream( assert result2["title"] == "127_0_0_1" assert result2["options"] == { - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", CONF_CONTENT_TYPE: "image/jpeg", - CONF_FRAMERATE: 5.0, - CONF_VERIFY_SSL: False, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 5.0, + CONF_VERIFY_SSL: False, + }, } with patch( @@ -447,9 +458,11 @@ async def test_form_still_and_stream_not_provided( result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], { - CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, - CONF_FRAMERATE: 5, - CONF_VERIFY_SSL: False, + SECTION_ADVANCED: { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 5, + CONF_VERIFY_SSL: False, + }, }, ) assert result2["type"] is FlowResultType.FORM @@ -887,8 +900,17 @@ async def test_migrate_existing_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that existing ids are migrated for issue #70568.""" + test_data = { + CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", + CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2", + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_USERNAME: "fred_flintstone", + CONF_PASSWORD: "bambam", + CONF_FRAMERATE: 5, + CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, + CONF_VERIFY_SSL: False, + } - test_data = TESTDATA_OPTIONS.copy() test_data[CONF_CONTENT_TYPE] = "image/png" old_unique_id = "54321" entity_id = "camera.sample_camera" @@ -934,9 +956,12 @@ async def test_options_use_wallclock_as_timestamps( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" + + data = deepcopy(TESTDATA) + data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + user_input=data, ) assert result2["type"] is FlowResultType.FORM @@ -966,7 +991,7 @@ async def test_options_use_wallclock_as_timestamps( assert result3["step_id"] == "init" result4 = await hass.config_entries.options.async_configure( result3["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + user_input=data, ) assert result4["type"] is FlowResultType.FORM assert result4["step_id"] == "user_confirm" diff --git a/tests/components/generic/test_diagnostics.py b/tests/components/generic/test_diagnostics.py index 80fa5fd4d4e..afd0bd0c42d 100644 --- a/tests/components/generic/test_diagnostics.py +++ b/tests/components/generic/test_diagnostics.py @@ -26,11 +26,13 @@ async def test_entry_diagnostics( "stream_source": "http://****:****@example.com/****", "username": REDACTED, "password": REDACTED, - "limit_refetch_to_url_change": False, - "authentication": "basic", - "framerate": 2.0, - "verify_ssl": True, "content_type": "image/jpeg", + "advanced": { + "limit_refetch_to_url_change": False, + "authentication": "basic", + "framerate": 2.0, + "verify_ssl": True, + }, }, } diff --git a/tests/components/generic/test_init.py b/tests/components/generic/test_init.py index faa00ee9144..d9a2665c915 100644 --- a/tests/components/generic/test_init.py +++ b/tests/components/generic/test_init.py @@ -2,7 +2,23 @@ import pytest +from homeassistant.components.generic.const import ( + CONF_CONTENT_TYPE, + CONF_FRAMERATE, + CONF_LIMIT_REFETCH_TO_URL_CHANGE, + CONF_STILL_IMAGE_URL, + CONF_STREAM_SOURCE, + DOMAIN, + SECTION_ADVANCED, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -35,3 +51,44 @@ async def test_reload_on_title_change( assert ( hass.states.get("camera.test_camera").attributes["friendly_name"] == "New Title" ) + + +@pytest.mark.usefixtures("fakeimg_png") +async def test_migration_to_version_2(hass: HomeAssistant) -> None: + """Test the File sensor with JSON entries.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Test Camera", + unique_id="abc123", + data={}, + options={ + CONF_STILL_IMAGE_URL: "http://joebloggs:letmein1@example.com/secret1/file.jpg?pw=qwerty", + CONF_STREAM_SOURCE: "http://janebloggs:letmein2@example.com/stream", + CONF_USERNAME: "johnbloggs", + CONF_PASSWORD: "letmein123", + CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_FRAMERATE: 2.0, + CONF_VERIFY_SSL: True, + CONF_CONTENT_TYPE: "image/jpeg", + }, + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.options == { + CONF_STILL_IMAGE_URL: "http://joebloggs:letmein1@example.com/secret1/file.jpg?pw=qwerty", + CONF_STREAM_SOURCE: "http://janebloggs:letmein2@example.com/stream", + CONF_USERNAME: "johnbloggs", + CONF_PASSWORD: "letmein123", + CONF_CONTENT_TYPE: "image/jpeg", + SECTION_ADVANCED: { + CONF_FRAMERATE: 2.0, + CONF_VERIFY_SSL: True, + CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + }, + } diff --git a/tests/components/gentex_homelink/__init__.py b/tests/components/gentex_homelink/__init__.py index 83a06ecb43a..d887d88772f 100644 --- a/tests/components/gentex_homelink/__init__.py +++ b/tests/components/gentex_homelink/__init__.py @@ -1 +1,39 @@ """Tests for the homelink integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +import jwt + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_CREDENTIALS = {CONF_EMAIL: "test@test.com", CONF_PASSWORD: "SomePassword"} + +TEST_ACCESS_JWT = jwt.encode({"sub": "some-uuid"}, key="secret") + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the homelink integration for testing.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def update_callback( + hass: HomeAssistant, mock: AsyncMock, update_type: str, data: dict[str, Any] +) -> None: + """Invoke the MQTT provider's message callback with the specified update type and data.""" + for call in mock.listen.call_args_list: + call[0][0]( + "topic", + { + "type": update_type, + "data": data, + }, + ) + + await hass.async_block_till_done() + await hass.async_block_till_done() diff --git a/tests/components/gentex_homelink/conftest.py b/tests/components/gentex_homelink/conftest.py new file mode 100644 index 00000000000..c9adcc6081d --- /dev/null +++ b/tests/components/gentex_homelink/conftest.py @@ -0,0 +1,87 @@ +"""Fixtures for Gentex HomeLink tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from homelink.model.button import Button +import homelink.model.device +import pytest + +from homeassistant.components.gentex_homelink import DOMAIN + +from . import TEST_ACCESS_JWT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_srp_auth() -> Generator[AsyncMock]: + """Mock SRP authentication.""" + with patch( + "homeassistant.components.gentex_homelink.config_flow.SRPAuth" + ) as mock_srp_auth: + instance = mock_srp_auth.return_value + instance.async_get_access_token.return_value = { + "AuthenticationResult": { + "AccessToken": TEST_ACCESS_JWT, + "RefreshToken": "refresh", + "TokenType": "bearer", + "ExpiresIn": 3600, + } + } + yield instance + + +@pytest.fixture +def mock_mqtt_provider(mock_device: AsyncMock) -> Generator[AsyncMock]: + """Mock MQTT provider.""" + with patch( + "homeassistant.components.gentex_homelink.MQTTProvider", autospec=True + ) as mock_mqtt_provider: + instance = mock_mqtt_provider.return_value + instance.discover.return_value = [mock_device] + yield instance + + +@pytest.fixture +def mock_device() -> AsyncMock: + """Mock Device instance.""" + device = AsyncMock(spec=homelink.model.device.Device, autospec=True) + buttons = [ + Button(id="1", name="Button 1", device=device), + Button(id="2", name="Button 2", device=device), + Button(id="3", name="Button 3", device=device), + ] + device.id = "TestDevice" + device.name = "TestDevice" + device.buttons = buttons + return device + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock setup entry.""" + return MockConfigEntry( + unique_id="some-uuid", + version=1, + domain=DOMAIN, + data={ + "auth_implementation": "gentex_homelink", + "token": { + "access_token": "access", + "refresh_token": "refresh", + "expires_in": 3600, + "token_type": "bearer", + "expires_at": 1234567890, + }, + }, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setup entry.""" + with patch( + "homeassistant.components.gentex_homelink.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/gentex_homelink/snapshots/test_event.ambr b/tests/components/gentex_homelink/snapshots/test_event.ambr new file mode 100644 index 00000000000..f0c6c3c8d16 --- /dev/null +++ b/tests/components/gentex_homelink/snapshots/test_event.ambr @@ -0,0 +1,172 @@ +# serializer version: 1 +# name: test_entities[event.testdevice_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'Pressed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.testdevice_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 1', + 'platform': 'gentex_homelink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.testdevice_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'Pressed', + ]), + 'friendly_name': 'TestDevice Button 1', + }), + 'context': , + 'entity_id': 'event.testdevice_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[event.testdevice_button_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'Pressed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.testdevice_button_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 2', + 'platform': 'gentex_homelink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.testdevice_button_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'Pressed', + ]), + 'friendly_name': 'TestDevice Button 2', + }), + 'context': , + 'entity_id': 'event.testdevice_button_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[event.testdevice_button_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'Pressed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.testdevice_button_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 3', + 'platform': 'gentex_homelink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.testdevice_button_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'Pressed', + ]), + 'friendly_name': 'TestDevice Button 3', + }), + 'context': , + 'entity_id': 'event.testdevice_button_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/gentex_homelink/snapshots/test_init.ambr b/tests/components/gentex_homelink/snapshots/test_init.ambr new file mode 100644 index 00000000000..d9d52e290bc --- /dev/null +++ b/tests/components/gentex_homelink/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'gentex_homelink', + 'TestDevice', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'TestDevice', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/gentex_homelink/test_config_flow.py b/tests/components/gentex_homelink/test_config_flow.py index 10f102b9103..4e2bffd3d25 100644 --- a/tests/components/gentex_homelink/test_config_flow.py +++ b/tests/components/gentex_homelink/test_config_flow.py @@ -1,91 +1,112 @@ """Test the homelink config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import botocore.exceptions +import pytest -from homeassistant import config_entries from homeassistant.components.gentex_homelink.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import TEST_ACCESS_JWT, TEST_CREDENTIALS, setup_integration -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_srp_auth: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Check full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_CREDENTIALS, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "auth_implementation": "gentex_homelink", + "token": { + "access_token": TEST_ACCESS_JWT, + "refresh_token": "refresh", + "expires_in": 3600, + "token_type": "bearer", + "expires_at": result["data"]["token"]["expires_at"], + }, + } + assert result["title"] == "SRPAuth" + assert result["result"].unique_id == "some-uuid" -async def test_full_flow(hass: HomeAssistant) -> None: +async def test_unique_configurations( + hass: HomeAssistant, + mock_srp_auth: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Check full flow.""" - with patch( - "homeassistant.components.gentex_homelink.config_flow.SRPAuth" - ) as MockSRPAuth: - instance = MockSRPAuth.return_value - instance.async_get_access_token.return_value = { - "AuthenticationResult": { - "AccessToken": "access", - "RefreshToken": "refresh", - "TokenType": "bearer", - "ExpiresIn": 3600, - } - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"email": "test@test.com", "password": "SomePassword"}, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] - assert result["data"]["token"] - assert result["data"]["token"]["access_token"] == "access" - assert result["data"]["token"]["refresh_token"] == "refresh" - assert result["data"]["token"]["expires_in"] == 3600 - assert result["data"]["token"]["expires_at"] + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_CREDENTIALS, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" -async def test_boto_error(hass: HomeAssistant) -> None: - """Test exceptions from boto are handled correctly.""" - with patch( - "homeassistant.components.gentex_homelink.config_flow.SRPAuth" - ) as MockSRPAuth: - instance = MockSRPAuth.return_value - instance.async_get_access_token.side_effect = botocore.exceptions.ClientError( - {"Error": {}}, "Some operation" - ) +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + botocore.exceptions.ClientError({"Error": {}}, "Some operation"), + "srp_auth_failed", + ), + (Exception("Some error"), "unknown"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_srp_auth: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions are handled correctly.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"email": "test@test.com", "password": "SomePassword"}, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + mock_srp_auth.async_get_access_token.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_CREDENTIALS, + ) -async def test_generic_error(hass: HomeAssistant) -> None: - """Test exceptions from boto are handled correctly.""" - with patch( - "homeassistant.components.gentex_homelink.config_flow.SRPAuth" - ) as MockSRPAuth: - instance = MockSRPAuth.return_value - instance.async_get_access_token.side_effect = Exception("Some error") + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"email": "test@test.com", "password": "SomePassword"}, - ) + mock_srp_auth.async_get_access_token.side_effect = None - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_CREDENTIALS, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/gentex_homelink/test_coordinator.py b/tests/components/gentex_homelink/test_coordinator.py deleted file mode 100644 index 2947e28839a..00000000000 --- a/tests/components/gentex_homelink/test_coordinator.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tests for the homelink coordinator.""" - -import asyncio -import time -from unittest.mock import patch - -from homelink.model.button import Button -from homelink.model.device import Device -import pytest - -from homeassistant.components.gentex_homelink import async_setup_entry -from homeassistant.components.gentex_homelink.const import EVENT_PRESSED -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - -DOMAIN = "gentex_homelink" - -deviceInst = Device(id="TestDevice", name="TestDevice") -deviceInst.buttons = [ - Button(id="Button 1", name="Button 1", device=deviceInst), - Button(id="Button 2", name="Button 2", device=deviceInst), - Button(id="Button 3", name="Button 3", device=deviceInst), -] - - -async def test_get_state_updates( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test state updates. - - Tests that get_state calls are called by home assistant, and the homeassistant components respond appropriately to the data returned. - """ - with patch( - "homeassistant.components.gentex_homelink.MQTTProvider", autospec=True - ) as MockProvider: - instance = MockProvider.return_value - instance.discover.return_value = [deviceInst] - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=None, - version=1, - data={ - "auth_implementation": "gentex_homelink", - "token": {"expires_at": time.time() + 10000, "access_token": ""}, - "last_update_id": None, - }, - state=ConfigEntryState.LOADED, - ) - config_entry.add_to_hass(hass) - result = await async_setup_entry(hass, config_entry) - # Assert configuration worked without errors - assert result - - provider = config_entry.runtime_data.provider - state_data = { - "type": "state", - "data": { - "Button 1": {"requestId": "rid1", "timestamp": time.time()}, - "Button 2": {"requestId": "rid2", "timestamp": time.time()}, - "Button 3": {"requestId": "rid3", "timestamp": time.time()}, - }, - } - - # Test successful setup and first data fetch. The buttons should be unknown at the start - await hass.async_block_till_done(wait_background_tasks=True) - states = hass.states.async_all() - assert states, "No states were loaded" - assert all(state != STATE_UNAVAILABLE for state in states), ( - "At least one state was not initialized as STATE_UNAVAILABLE" - ) - buttons_unknown = [s.state == "unknown" for s in states] - assert buttons_unknown and all(buttons_unknown), ( - "At least one button state was not initialized to unknown" - ) - - provider.listen.mock_calls[0].args[0](None, state_data) - - await hass.async_block_till_done(wait_background_tasks=True) - await asyncio.gather(*asyncio.all_tasks() - {asyncio.current_task()}) - await asyncio.sleep(0.01) - states = hass.states.async_all() - - assert all(state != STATE_UNAVAILABLE for state in states), ( - "Some button became unavailable" - ) - buttons_pressed = [s.attributes["event_type"] == EVENT_PRESSED for s in states] - assert buttons_pressed and all(buttons_pressed), ( - "At least one button was not pressed" - ) - - -async def test_request_sync(hass: HomeAssistant) -> None: - """Test that the config entry is reloaded when a requestSync request is sent.""" - updatedDeviceInst = Device(id="TestDevice", name="TestDevice") - updatedDeviceInst.buttons = [ - Button(id="Button 1", name="New Button 1", device=deviceInst), - Button(id="Button 2", name="New Button 2", device=deviceInst), - Button(id="Button 3", name="New Button 3", device=deviceInst), - ] - - with patch( - "homeassistant.components.gentex_homelink.MQTTProvider", autospec=True - ) as MockProvider: - instance = MockProvider.return_value - instance.discover.side_effect = [[deviceInst], [updatedDeviceInst]] - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=None, - version=1, - data={ - "auth_implementation": "gentex_homelink", - "token": {"expires_at": time.time() + 10000, "access_token": ""}, - "last_update_id": None, - }, - state=ConfigEntryState.LOADED, - ) - config_entry.add_to_hass(hass) - result = await async_setup_entry(hass, config_entry) - # Assert configuration worked without errors - assert result - - # Check to see if the correct buttons names were loaded - comp = er.async_get(hass) - button_names = {"Button 1", "Button 2", "Button 3"} - registered_button_names = {b.original_name for b in comp.entities.values()} - - assert button_names == registered_button_names, ( - "Expect button names to be correct for the initial config" - ) - - provider = config_entry.runtime_data.provider - coordinator = config_entry.runtime_data.coordinator - - with patch.object( - coordinator.hass.config_entries, "async_reload" - ) as async_reload_mock: - # Mimic request sync event - state_data = { - "type": "requestSync", - } - # async reload should not be called yet - async_reload_mock.assert_not_called() - # Send the request sync - provider.listen.mock_calls[0].args[0](None, state_data) - # Wait for the request to be processed - await hass.async_block_till_done(wait_background_tasks=True) - await asyncio.gather(*asyncio.all_tasks() - {asyncio.current_task()}) - await asyncio.sleep(0.01) - - # Now async reload should have been called - async_reload_mock.assert_called() diff --git a/tests/components/gentex_homelink/test_event.py b/tests/components/gentex_homelink/test_event.py index d3661f94492..9b07180a157 100644 --- a/tests/components/gentex_homelink/test_event.py +++ b/tests/components/gentex_homelink/test_event.py @@ -1,77 +1,55 @@ """Test that the devices and entities are correctly configured.""" -from unittest.mock import patch +import time +from unittest.mock import AsyncMock -from homelink.model.button import Button -from homelink.model.device import Device import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.gentex_homelink import async_setup_entry -from homeassistant.components.gentex_homelink.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator +from . import setup_integration, update_callback -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -TEST_CONFIG_ENTRY_ID = "ABC123" - -"""Mock classes for testing.""" +from tests.common import MockConfigEntry, snapshot_platform -deviceInst = Device(id="TestDevice", name="TestDevice") -deviceInst.buttons = [ - Button(id="1", name="Button 1", device=deviceInst), - Button(id="2", name="Button 2", device=deviceInst), - Button(id="3", name="Button 3", device=deviceInst), -] - - -@pytest.fixture -async def test_setup_config( +async def test_entities( hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - caplog: pytest.LogCaptureFixture, + mock_config_entry: MockConfigEntry, + mock_mqtt_provider: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Setup config entry.""" - with patch( - "homeassistant.components.gentex_homelink.MQTTProvider", autospec=True - ) as MockProvider: - instance = MockProvider.return_value - instance.discover.return_value = [deviceInst] - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=None, - version=1, - data={"auth_implementation": "gentex_homelink"}, - state=ConfigEntryState.LOADED, - ) - config_entry.add_to_hass(hass) - result = await async_setup_entry(hass, config_entry) - - # Assert configuration worked without errors - assert result - - -async def test_device_registered(hass: HomeAssistant, test_setup_config) -> None: - """Check if a device is registered.""" - # Assert we got a device with the test ID - device_registry = dr.async_get(hass) - device = device_registry.async_get_device([(DOMAIN, "TestDevice")]) - assert device - assert device.name == "TestDevice" - - -def test_entities_registered(hass: HomeAssistant, test_setup_config) -> None: """Check if the entities are registered.""" - comp = er.async_get(hass) - button_names = {"Button 1", "Button 2", "Button 3"} - registered_button_names = {b.original_name for b in comp.entities.values()} + await setup_integration(hass, mock_config_entry) - assert button_names == registered_button_names + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2021-07-30") +async def test_entities_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_provider: AsyncMock, +) -> None: + """Check if the entities are updated.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.testdevice_button_1").state == STATE_UNKNOWN + + await update_callback( + hass, + mock_mqtt_provider, + "state", + { + "1": {"requestId": "rid1", "timestamp": time.time()}, + "2": {"requestId": "rid2", "timestamp": time.time()}, + "3": {"requestId": "rid3", "timestamp": time.time()}, + }, + ) + assert ( + hass.states.get("event.testdevice_button_1").state + == "2021-07-30T00:00:00.000+00:00" + ) diff --git a/tests/components/gentex_homelink/test_init.py b/tests/components/gentex_homelink/test_init.py index a8a7b3e7675..d5e226c2ae0 100644 --- a/tests/components/gentex_homelink/test_init.py +++ b/tests/components/gentex_homelink/test_init.py @@ -1,32 +1,66 @@ """Test that the integration is initialized correctly.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion -from homeassistant.components import gentex_homelink from homeassistant.components.gentex_homelink.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +import homeassistant.helpers.device_registry as dr + +from . import setup_integration, update_callback from tests.common import MockConfigEntry +async def test_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_provider: AsyncMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device is registered correctly.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "TestDevice")}, + ) + assert device + assert device == snapshot + + +async def test_reload_sync( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_provider: AsyncMock, +) -> None: + """Test that the config entry is reloaded when a requestSync request is sent.""" + await setup_integration(hass, mock_config_entry) + + with patch.object(hass.config_entries, "async_reload") as async_reload_mock: + await update_callback( + hass, + mock_mqtt_provider, + "requestSync", + {}, + ) + + async_reload_mock.assert_called_once_with(mock_config_entry.entry_id) + + async def test_load_unload_entry( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_provider: AsyncMock, ) -> None: """Test the entry can be loaded and unloaded.""" - with patch("homeassistant.components.gentex_homelink.MQTTProvider", autospec=True): - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=None, - version=1, - data={"auth_implementation": "gentex_homelink"}, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - assert await async_setup_component(hass, DOMAIN, {}) is True, ( - "Component is not set up" - ) + assert mock_config_entry.state is ConfigEntryState.LOADED - assert await gentex_homelink.async_unload_entry(hass, entry), ( - "Component not unloaded" - ) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/gios/conftest.py b/tests/components/gios/conftest.py index 3ab1a70ed79..fd54beae3b6 100644 --- a/tests/components/gios/conftest.py +++ b/tests/components/gios/conftest.py @@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from gios.model import GiosSensors, GiosStation, Sensor as GiosSensor import pytest -from homeassistant.components.gios.const import DOMAIN +from homeassistant.components.gios.const import CONF_STATION_ID, DOMAIN +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from . import setup_integration @@ -21,7 +22,10 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="Home", unique_id="123", - data={"station_id": 123, "name": "Home"}, + data={ + CONF_STATION_ID: 123, + CONF_NAME: "Home", + }, entry_id="86129426118ae32020417a53712d6eef", ) @@ -49,7 +53,7 @@ def mock_gios_sensors() -> GiosSensors: def mock_gios_stations() -> dict[int, GiosStation]: """Return the default mocked gios stations.""" return { - 123: GiosStation(id=123, name="Test Name 1", latitude=99.99, longitude=88.88), + 123: GiosStation(id=123, name="Home", latitude=99.99, longitude=88.88), 321: GiosStation(id=321, name="Test Name 2", latitude=77.77, longitude=66.66), } diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index 715e15318f8..b7229c621be 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType CONFIG = { - CONF_NAME: "Foo", CONF_STATION_ID: "123", } @@ -68,12 +67,13 @@ async def test_form_submission_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == errors + mock_gios.async_update.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Test Name 1" + assert result["title"] == "Home" async def test_create_entry(hass: HomeAssistant) -> None: @@ -87,7 +87,10 @@ async def test_create_entry(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Test Name 1" - assert result["data"][CONF_STATION_ID] == 123 + assert result["title"] == "Home" + assert result["data"] == { + CONF_STATION_ID: 123, + CONF_NAME: "Home", + } assert result["result"].unique_id == "123" diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 426cfc5f01e..63c9b31ef56 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -96,7 +96,7 @@ async def _test_setup_and_signaling( config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 1 config_entry = config_entries[0] - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED after_setup_fn() receive_message_callback = Mock(spec_set=WebRTCSendMessage) @@ -183,7 +183,7 @@ async def _test_setup_and_signaling( await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED assert teardown.call_count == 2 @@ -625,7 +625,7 @@ async def test_setup_with_setup_entry_error( await hass.async_block_till_done(wait_background_tasks=True) config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.SETUP_ERROR + assert config_entries[0].state is ConfigEntryState.SETUP_ERROR assert expected_log_message in caplog.text diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 402c2df78d0..7457b99133e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -693,7 +693,7 @@ async def test_startstop_lawn_mower(hass: HomeAssistant) -> None: ), ], ) -async def test_startstop_cover_valve( +async def test_startstop_cover_valve_no_assumed_state( hass: HomeAssistant, domain: str, state_open: str, @@ -706,14 +706,14 @@ async def test_startstop_cover_valve( service_stop: str, service_toggle: str, ) -> None: - """Test startStop trait support.""" + """Test startStop trait support and no assumed state.""" assert helpers.get_google_type(domain, None) is not None assert trait.StartStopTrait.supported(domain, supported_features, None, None) state = State( f"{domain}.bla", state_closed, - {ATTR_SUPPORTED_FEATURES: supported_features}, + {ATTR_SUPPORTED_FEATURES: supported_features, ATTR_ASSUMED_STATE: False}, ) trt = trait.StartStopTrait( @@ -773,6 +773,168 @@ async def test_startstop_cover_valve( await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"start": True}, {}) +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + "assumed_state", + ), + [ + ( + cover.DOMAIN, + cover.CoverState.OPEN, + cover.CoverState.CLOSED, + cover.CoverState.OPENING, + cover.CoverState.CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + True, + ), + ( + valve.DOMAIN, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, + valve.ValveState.OPENING, + valve.ValveState.CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + True, + ), + ( + cover.DOMAIN, + cover.CoverState.OPEN, + cover.CoverState.CLOSED, + cover.CoverState.OPENING, + cover.CoverState.CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + False, + ), + ( + valve.DOMAIN, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, + valve.ValveState.OPENING, + valve.ValveState.CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + False, + ), + ], +) +async def test_startstop_cover_valve_with_assumed_state_or_reports_position( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, + assumed_state: bool, +) -> None: + """Test startStop trait support without an assumed state or reporting position.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.StartStopTrait.supported(domain, supported_features, None, None) + + state = State( + f"{domain}.bla", + state_closed, + { + ATTR_SUPPORTED_FEATURES: supported_features, + ATTR_ASSUMED_STATE: assumed_state, + }, + ) + + trt = trait.StartStopTrait( + hass, + state, + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {} + + for state_value in (state_closing, state_opening): + state.state = state_value + assert trt.query_attributes()["isRunning"] is True + + stop_calls = async_mock_service(hass, domain, service_stop) + open_calls = async_mock_service(hass, domain, service_open) + close_calls = async_mock_service(hass, domain, service_close) + toggle_calls = async_mock_service(hass, domain, service_toggle) + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": False}, {}) + assert len(stop_calls) == 1 + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + + # Trait attr isRunning always returns True, + # so the cover or valve can always be stopped + for state_value in (state_closing, state_opening, state_closed, state_open): + state.state = state_value + assert trt.query_attributes()["isRunning"] is True + + state.state = state_open + + # Stop does not raise because we assume the state + # or the position is reported + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": False}, {}) + assert len(stop_calls) == 2 + + # Start triggers toggle open + state.state = state_closed + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + # Second start triggers toggle close + state.state = state_open + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 2 + assert toggle_calls[1].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + + state.state = state_closed + with pytest.raises( + SmartHomeError, + match="Command action.devices.commands.PauseUnpause is not supported", + ): + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"start": True}, {}) + + @pytest.mark.parametrize( ( "domain", diff --git a/tests/components/google_mail/test_notify.py b/tests/components/google_mail/test_notify.py index 7373047b46e..1e42bd886d3 100644 --- a/tests/components/google_mail/test_notify.py +++ b/tests/components/google_mail/test_notify.py @@ -7,6 +7,7 @@ from voluptuous.error import Invalid from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .conftest import BUILD, ComponentSetup @@ -45,6 +46,40 @@ async def test_notify( ) assert len(mock_client.mock_calls) == 5 + with pytest.raises(ServiceValidationError) as ex: + await hass.services.async_call( + NOTIFY_DOMAIN, + "example_gmail_com", + { + "title": "Test", + "message": "test email", + "target": "text@example.com", + "data": {"send": False, "alias_from": "Alias Test"}, + }, + blocking=True, + ) + assert ex.match( + "Missing 'from' email when setting an alias to show. You have to provide a 'from' email" + ) + + with patch(BUILD) as mock_client: + await hass.services.async_call( + NOTIFY_DOMAIN, + "example_gmail_com", + { + "title": "Test", + "message": "test email", + "target": "text@example.com", + "data": { + "send": False, + "alias_from": "Alias Test", + "from": "example@gmail.com", + }, + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 5 + async def test_notify_voluptuous_error( hass: HomeAssistant, diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 5081533daf4..bf751513ec2 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Growatt server tests.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -36,6 +36,9 @@ def mock_growatt_v1_api(): Methods mocked for switch and number operations: - min_write_parameter: Called by switch/number entities to change settings + + Methods mocked for service operations: + - min_write_time_segment: Called by time segment management services """ with patch( "homeassistant.components.growatt_server.config_flow.growattServer.OpenApiV1", @@ -65,15 +68,45 @@ def mock_growatt_v1_api(): # Called by MIN device coordinator during refresh mock_v1_api.min_settings.return_value = { - # Forced charge time segments (not used by switch/number, but coordinator fetches it) + # Time segment 1 - enabled, load_first mode "forcedTimeStart1": "06:00", "forcedTimeStop1": "08:00", - "forcedChargeBatMode1": 1, - "forcedChargeFlag1": 1, + "time1Mode": 1, # load_first + "forcedStopSwitch1": 1, # enabled + # Time segment 2 - disabled "forcedTimeStart2": "22:00", "forcedTimeStop2": "24:00", - "forcedChargeBatMode2": 0, - "forcedChargeFlag2": 0, + "time2Mode": 0, # battery_first + "forcedStopSwitch2": 0, # disabled + # Time segments 3-9 - all disabled with default values + "forcedTimeStart3": "00:00", + "forcedTimeStop3": "00:00", + "time3Mode": 1, + "forcedStopSwitch3": 0, + "forcedTimeStart4": "00:00", + "forcedTimeStop4": "00:00", + "time4Mode": 1, + "forcedStopSwitch4": 0, + "forcedTimeStart5": "00:00", + "forcedTimeStop5": "00:00", + "time5Mode": 1, + "forcedStopSwitch5": 0, + "forcedTimeStart6": "00:00", + "forcedTimeStop6": "00:00", + "time6Mode": 1, + "forcedStopSwitch6": 0, + "forcedTimeStart7": "00:00", + "forcedTimeStop7": "00:00", + "time7Mode": 1, + "forcedStopSwitch7": 0, + "forcedTimeStart8": "00:00", + "forcedTimeStop8": "00:00", + "time8Mode": 1, + "forcedStopSwitch8": 0, + "forcedTimeStart9": "00:00", + "forcedTimeStop9": "00:00", + "time9Mode": 1, + "forcedStopSwitch9": 0, } # Called by MIN device coordinator during refresh @@ -101,6 +134,15 @@ def mock_growatt_v1_api(): # Called by switch/number entities during turn_on/turn_off/set_value mock_v1_api.min_write_parameter.return_value = None + # Called by time segment management services + # Note: Don't use autospec for this method as it needs to accept variable arguments + mock_v1_api.min_write_time_segment = Mock( + return_value={ + "error_code": 0, + "error_msg": "Success", + } + ) + yield mock_v1_api diff --git a/tests/components/growatt_server/snapshots/test_sensor.ambr b/tests/components/growatt_server/snapshots/test_sensor.ambr index 6a39175de95..226821e39f0 100644 --- a/tests/components/growatt_server/snapshots/test_sensor.ambr +++ b/tests/components/growatt_server/snapshots/test_sensor.ambr @@ -229,7 +229,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -268,6 +270,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Battery 1 charging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -283,7 +286,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -322,6 +327,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Battery 1 discharging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -337,7 +343,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -376,6 +384,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Battery 2 charging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -391,7 +400,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -430,6 +441,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Battery 2 discharging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -730,7 +742,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -769,6 +783,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Export power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -898,7 +913,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -937,6 +954,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Import power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1060,7 +1078,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1099,6 +1119,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Input 1 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1222,7 +1243,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1261,6 +1284,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Input 2 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1384,7 +1408,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1423,6 +1449,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Input 3 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1546,7 +1573,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1585,6 +1614,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Input 4 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1600,7 +1630,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1639,6 +1671,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Internal wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2737,7 +2770,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2776,6 +2811,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Local load power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2791,7 +2827,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2830,6 +2868,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Output power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2956,7 +2995,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2995,6 +3036,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 Self power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3118,7 +3160,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3157,6 +3201,7 @@ 'device_class': 'power', 'friendly_name': 'MIN123456 System power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3613,7 +3658,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3652,6 +3699,7 @@ 'device_class': 'power', 'friendly_name': 'Test Plant Total Maximum power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3936,7 +3984,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3975,6 +4025,7 @@ 'device_class': 'power', 'friendly_name': 'Test Plant Total Maximum power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4372,7 +4423,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4411,6 +4464,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Battery 1 charging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4426,7 +4480,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4465,6 +4521,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Battery 1 discharging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4480,7 +4537,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4519,6 +4578,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Battery 2 charging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4534,7 +4594,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4573,6 +4635,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Battery 2 discharging W', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4873,7 +4936,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4912,6 +4977,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Export power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5041,7 +5107,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5080,6 +5148,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Import power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5203,7 +5272,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5242,6 +5313,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Input 1 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5365,7 +5437,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5404,6 +5478,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Input 2 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5527,7 +5602,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5566,6 +5643,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Input 3 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5689,7 +5767,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5728,6 +5808,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Input 4 wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5743,7 +5824,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5782,6 +5865,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Internal wattage', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -6880,7 +6964,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -6919,6 +7005,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Local load power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -6934,7 +7021,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -6973,6 +7062,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Output power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -7099,7 +7189,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -7138,6 +7230,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 Self power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -7261,7 +7354,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -7300,6 +7395,7 @@ 'device_class': 'power', 'friendly_name': 'TLX123456 System power', 'icon': 'mdi:solar-power', + 'state_class': , 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/growatt_server/snapshots/test_services.ambr b/tests/components/growatt_server/snapshots/test_services.ambr new file mode 100644 index 00000000000..ff43b89d231 --- /dev/null +++ b/tests/components/growatt_server/snapshots/test_services.ambr @@ -0,0 +1,70 @@ +# serializer version: 1 +# name: test_read_time_segments_single_device + dict({ + 'time_segments': list([ + dict({ + 'batt_mode': 'battery_first', + 'enabled': True, + 'end_time': '08:00', + 'segment_id': 1, + 'start_time': '06:00', + }), + dict({ + 'batt_mode': 'load_first', + 'enabled': False, + 'end_time': '24:00', + 'segment_id': 2, + 'start_time': '22:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 3, + 'start_time': '00:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 4, + 'start_time': '00:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 5, + 'start_time': '00:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 6, + 'start_time': '00:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 7, + 'start_time': '00:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 8, + 'start_time': '00:00', + }), + dict({ + 'batt_mode': 'battery_first', + 'enabled': False, + 'end_time': '00:00', + 'segment_id': 9, + 'start_time': '00:00', + }), + ]), + }) +# --- diff --git a/tests/components/growatt_server/test_services.py b/tests/components/growatt_server/test_services.py new file mode 100644 index 00000000000..cd181e05597 --- /dev/null +++ b/tests/components/growatt_server/test_services.py @@ -0,0 +1,586 @@ +"""Test Growatt Server services.""" + +from unittest.mock import patch + +import growattServer +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.growatt_server.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_growatt_v1_api") +async def test_read_time_segments_single_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test reading time segments for single device.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test service call + response = await hass.services.async_call( + DOMAIN, + "read_time_segments", + {"device_id": device_entry.id}, + blocking=True, + return_response=True, + ) + + assert response == snapshot + + +async def test_update_time_segment_charge_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test updating time segment with charge mode.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test successful update + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + # Verify the API was called + mock_growatt_v1_api.min_write_time_segment.assert_called_once() + + +async def test_update_time_segment_discharge_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test updating time segment with discharge mode.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 2, + "start_time": "14:00", + "end_time": "16:00", + "batt_mode": "battery_first", + "enabled": True, + }, + blocking=True, + ) + + mock_growatt_v1_api.min_write_time_segment.assert_called_once() + + +async def test_update_time_segment_standby_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test updating time segment with standby mode.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 3, + "start_time": "20:00", + "end_time": "22:00", + "batt_mode": "grid_first", + "enabled": True, + }, + blocking=True, + ) + + mock_growatt_v1_api.min_write_time_segment.assert_called_once() + + +async def test_update_time_segment_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test disabling a time segment.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "06:00", + "end_time": "08:00", + "batt_mode": "load_first", + "enabled": False, + }, + blocking=True, + ) + + mock_growatt_v1_api.min_write_time_segment.assert_called_once() + + +async def test_update_time_segment_with_seconds( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test updating time segment with HH:MM:SS format from UI.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test with HH:MM:SS format (what the UI time selector sends) + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "09:00:00", + "end_time": "11:00:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + mock_growatt_v1_api.min_write_time_segment.assert_called_once() + + +async def test_update_time_segment_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling API error when updating time segment.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Mock API error - the library raises an exception instead of returning error dict + mock_growatt_v1_api.min_write_time_segment.side_effect = ( + growattServer.GrowattV1ApiError( + "Error during writing time segment 1", + error_code=1, + error_msg="API Error", + ) + ) + + with pytest.raises(HomeAssistantError, match="API error updating time segment"): + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_growatt_classic_api") +async def test_no_min_devices_skips_service_registration( + hass: HomeAssistant, + mock_config_entry_classic: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that services fail gracefully when no MIN devices exist.""" + mock_config_entry_classic.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + # Only non-MIN devices (TLX with classic API) + mock_get_devices.return_value = ( + [{"deviceSn": "TLX123456", "deviceType": "tlx"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry_classic.entry_id) + await hass.async_block_till_done() + + # Verify services are registered (they're always registered in async_setup) + assert hass.services.has_service(DOMAIN, "update_time_segment") + assert hass.services.has_service(DOMAIN, "read_time_segments") + + # Get the TLX device (non-MIN) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")}) + assert device_entry is not None + + # But calling them with a non-MIN device should fail with appropriate error + with pytest.raises( + ServiceValidationError, match="No MIN devices with token authentication" + ): + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + +async def test_multiple_devices_with_valid_device_id_works( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that multiple devices work when device_id is specified.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [ + {"deviceSn": "MIN123456", "deviceType": "min"}, + {"deviceSn": "MIN789012", "deviceType": "min"}, + ], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID for the first MIN device + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test update service with specific device_id (device registry ID) + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + mock_growatt_v1_api.min_write_time_segment.assert_called_once() + + # Test read service with specific device_id (device registry ID) + response = await hass.services.async_call( + DOMAIN, + "read_time_segments", + {"device_id": device_entry.id}, + blocking=True, + return_response=True, + ) + + assert response is not None + assert "time_segments" in response + + +@pytest.mark.usefixtures("mock_growatt_v1_api") +async def test_update_time_segment_invalid_time_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling invalid time format in update_time_segment.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test with invalid time format + with pytest.raises( + ServiceValidationError, match="start_time must be in HH:MM or HH:MM:SS format" + ): + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "invalid", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_growatt_v1_api") +async def test_update_time_segment_invalid_segment_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test validation of segment_id range.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test segment_id too low + with pytest.raises( + ServiceValidationError, match="segment_id must be between 1 and 9" + ): + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 0, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + # Test segment_id too high + with pytest.raises( + ServiceValidationError, match="segment_id must be between 1 and 9" + ): + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 10, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "load_first", + "enabled": True, + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_growatt_v1_api") +async def test_update_time_segment_invalid_batt_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test validation of batt_mode value.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Test invalid batt_mode + with pytest.raises(ServiceValidationError, match="batt_mode must be one of"): + await hass.services.async_call( + DOMAIN, + "update_time_segment", + { + "device_id": device_entry.id, + "segment_id": 1, + "start_time": "09:00", + "end_time": "11:00", + "batt_mode": "invalid_mode", + "enabled": True, + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_growatt_v1_api") +async def test_read_time_segments_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling API error when reading time segments.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.growatt_server.get_device_list" + ) as mock_get_devices: + mock_get_devices.return_value = ( + [{"deviceSn": "MIN123456", "deviceType": "min"}], + "12345", + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device registry ID + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device_entry is not None + + # Mock API error by making coordinator.read_time_segments raise an exception + with ( + patch( + "homeassistant.components.growatt_server.coordinator.GrowattCoordinator.read_time_segments", + side_effect=HomeAssistantError("API connection failed"), + ), + pytest.raises(HomeAssistantError, match="API connection failed"), + ): + await hass.services.async_call( + DOMAIN, + "read_time_segments", + {"device_id": device_entry.id}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index b4a23202f9d..8008d5ae5f4 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1,7 +1,6 @@ """The tests for the hassio component.""" from datetime import timedelta -import logging import os from typing import Any from unittest.mock import AsyncMock, patch @@ -13,15 +12,13 @@ import pytest from voluptuous import Invalid from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components import frontend, hassio +from homeassistant.components import frontend from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.hassio import ( ADDONS_COORDINATOR, DOMAIN, get_core_info, - get_supervisor_ip, hostname_from_addon_slug, - is_hassio as deprecated_is_hassio, ) from homeassistant.components.hassio.config import STORAGE_KEY from homeassistant.components.hassio.const import ( @@ -32,15 +29,10 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.hassio import is_hassio -from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - import_and_test_deprecated_constant, -) +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @@ -1089,69 +1081,6 @@ def test_hostname_from_addon_slug() -> None: ) -def test_deprecated_function_is_hassio( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test calling deprecated_is_hassio function will create log entry.""" - - deprecated_is_hassio(hass) - assert caplog.record_tuples == [ - ( - "homeassistant.components.hassio", - logging.WARNING, - "The deprecated function is_hassio was called. It will be " - "removed in HA Core 2025.11. Use homeassistant.helpers" - ".hassio.is_hassio instead", - ) - ] - - -def test_deprecated_function_get_supervisor_ip( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test calling get_supervisor_ip function will create log entry.""" - - get_supervisor_ip() - assert caplog.record_tuples == [ - ( - "homeassistant.helpers.hassio", - logging.WARNING, - "The deprecated function get_supervisor_ip was called. It will " - "be removed in HA Core 2025.11. Use homeassistant.helpers" - ".hassio.get_supervisor_ip instead", - ) - ] - - -@pytest.mark.parametrize( - ("constant_name", "replacement_name", "replacement"), - [ - ( - "HassioServiceInfo", - "homeassistant.helpers.service_info.hassio.HassioServiceInfo", - HassioServiceInfo, - ), - ], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - constant_name: str, - replacement_name: str, - replacement: Any, -) -> None: - """Test deprecated automation constants.""" - import_and_test_deprecated_constant( - caplog, - hassio, - constant_name, - replacement_name, - replacement, - "2025.11", - ) - - @pytest.mark.parametrize( ("board", "issue_id"), [ diff --git a/tests/components/hikvision/__init__.py b/tests/components/hikvision/__init__.py new file mode 100644 index 00000000000..15c5f6c5745 --- /dev/null +++ b/tests/components/hikvision/__init__.py @@ -0,0 +1,14 @@ +"""Common test tools for the Hikvision integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Hikvision integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/hikvision/conftest.py b/tests/components/hikvision/conftest.py new file mode 100644 index 00000000000..758906629ea --- /dev/null +++ b/tests/components/hikvision/conftest.py @@ -0,0 +1,94 @@ +"""Common fixtures for the Hikvision tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.hikvision.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_HOST = "192.168.1.100" +TEST_PORT = 80 +TEST_USERNAME = "admin" +TEST_PASSWORD = "password123" +TEST_DEVICE_ID = "DS-2CD2142FWD-I20170101AAAA" +TEST_DEVICE_NAME = "Front Camera" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.hikvision.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=TEST_DEVICE_NAME, + domain=DOMAIN, + version=1, + minor_version=1, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + unique_id=TEST_DEVICE_ID, + ) + + +@pytest.fixture +def mock_hikcamera() -> Generator[MagicMock]: + """Return a mocked HikCamera.""" + with ( + patch( + "homeassistant.components.hikvision.HikCamera", + autospec=True, + ) as hikcamera_mock, + patch( + "homeassistant.components.hikvision.config_flow.HikCamera", + new=hikcamera_mock, + ), + ): + camera = hikcamera_mock.return_value + camera.get_id = TEST_DEVICE_ID + camera.get_name = TEST_DEVICE_NAME + camera.get_type = "Camera" + camera.current_event_states = { + "Motion": [(True, 1)], + "Line Crossing": [(False, 1)], + } + camera.fetch_attributes.return_value = ( + False, + None, + None, + "2024-01-01T00:00:00Z", + ) + yield hikcamera_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hikcamera: MagicMock +) -> MockConfigEntry: + """Set up the Hikvision integration for testing.""" + await setup_integration(hass, mock_config_entry) + return mock_config_entry diff --git a/tests/components/hikvision/snapshots/test_binary_sensor.ambr b/tests/components/hikvision/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2d77654cd58 --- /dev/null +++ b/tests/components/hikvision/snapshots/test_binary_sensor.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.front_camera_line_crossing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_camera_line_crossing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line Crossing', + 'platform': 'hikvision', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DS-2CD2142FWD-I20170101AAAA_Line Crossing_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.front_camera_line_crossing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Front Camera Line Crossing', + 'last_tripped_time': '2024-01-01T00:00:00Z', + }), + 'context': , + 'entity_id': 'binary_sensor.front_camera_line_crossing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.front_camera_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_camera_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'hikvision', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DS-2CD2142FWD-I20170101AAAA_Motion_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.front_camera_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Front Camera Motion', + 'last_tripped_time': '2024-01-01T00:00:00Z', + }), + 'context': , + 'entity_id': 'binary_sensor.front_camera_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py new file mode 100644 index 00000000000..5eff8508957 --- /dev/null +++ b/tests/components/hikvision/test_binary_sensor.py @@ -0,0 +1,300 @@ +"""Test Hikvision binary sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.hikvision.const import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_LAST_TRIP_TIME, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + STATE_OFF, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .conftest import ( + TEST_DEVICE_ID, + TEST_DEVICE_NAME, + TEST_HOST, + TEST_PASSWORD, + TEST_PORT, + TEST_USERNAME, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all binary sensor entities.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_binary_sensors_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test binary sensors are created for each event type.""" + await setup_integration(hass, mock_config_entry) + + # Check Motion sensor (camera type doesn't include channel in name) + state = hass.states.get("binary_sensor.front_camera_motion") + assert state is not None + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.MOTION + assert ATTR_LAST_TRIP_TIME in state.attributes + + # Check Line Crossing sensor + state = hass.states.get("binary_sensor.front_camera_line_crossing") + assert state is not None + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.MOTION + + +async def test_binary_sensor_device_info( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test binary sensors are linked to device.""" + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_DEVICE_ID)} + ) + assert device_entry is not None + assert device_entry.name == TEST_DEVICE_NAME + assert device_entry.manufacturer == "Hikvision" + assert device_entry.model == "Camera" + + +async def test_binary_sensor_callback_registered( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test that callback is registered with pyhik.""" + await setup_integration(hass, mock_config_entry) + + # Verify callback was registered for each sensor + assert mock_hikcamera.return_value.add_update_callback.call_count == 2 + + +async def test_binary_sensor_no_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test setup when device has no sensors.""" + mock_hikcamera.return_value.current_event_states = None + + await setup_integration(hass, mock_config_entry) + + # No binary sensors should be created + states = hass.states.async_entity_ids("binary_sensor") + assert len(states) == 0 + + +async def test_binary_sensor_nvr_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test binary sensor naming for NVR devices.""" + mock_hikcamera.return_value.get_type = "NVR" + mock_hikcamera.return_value.current_event_states = { + "Motion": [(True, 1), (False, 2)], + } + + await setup_integration(hass, mock_config_entry) + + # NVR sensors should include channel number in name + state = hass.states.get("binary_sensor.front_camera_motion_1") + assert state is not None + + state = hass.states.get("binary_sensor.front_camera_motion_2") + assert state is not None + + +async def test_binary_sensor_state_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test binary sensor state when on.""" + mock_hikcamera.return_value.fetch_attributes.return_value = ( + True, + None, + None, + "2024-01-01T12:00:00Z", + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.front_camera_motion") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_device_class_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test binary sensor with unknown device class.""" + mock_hikcamera.return_value.current_event_states = { + "Unknown Event": [(False, 1)], + } + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.front_camera_unknown_event") + assert state is not None + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + + +async def test_yaml_import_creates_deprecation_issue( + hass: HomeAssistant, + mock_hikcamera: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test YAML import creates deprecation issue.""" + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": DOMAIN, + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + } + }, + ) + await hass.async_block_till_done() + + # Check that deprecation issue was created in homeassistant domain + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_yaml_import_with_name( + hass: HomeAssistant, + mock_hikcamera: MagicMock, +) -> None: + """Test YAML import uses custom name for config entry.""" + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": DOMAIN, + CONF_NAME: "Custom Camera Name", + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + } + }, + ) + await hass.async_block_till_done() + + # Check that the config entry was created with the custom name + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].title == "Custom Camera Name" + + +async def test_yaml_import_abort_creates_issue( + hass: HomeAssistant, + mock_hikcamera: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test YAML import creates issue when import is aborted.""" + mock_hikcamera.return_value.get_id = None + + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": DOMAIN, + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + } + }, + ) + await hass.async_block_till_done() + + # Check that import failure issue was created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_binary_sensor_update_callback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test binary sensor state updates via callback.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.front_camera_motion") + assert state is not None + assert state.state == STATE_OFF + + # Simulate state change via callback + mock_hikcamera.return_value.fetch_attributes.return_value = ( + True, + None, + None, + "2024-01-01T12:00:00Z", + ) + + # Get the registered callback and call it + add_callback_call = mock_hikcamera.return_value.add_update_callback.call_args_list[ + 0 + ] + callback_func = add_callback_call[0][0] + callback_func("motion detected") + + # Verify state was updated + state = hass.states.get("binary_sensor.front_camera_motion") + assert state is not None + assert state.state == "on" diff --git a/tests/components/hikvision/test_config_flow.py b/tests/components/hikvision/test_config_flow.py new file mode 100644 index 00000000000..46081077a17 --- /dev/null +++ b/tests/components/hikvision/test_config_flow.py @@ -0,0 +1,352 @@ +"""Test the Hikvision config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +import requests + +from homeassistant.components.hikvision.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import ( + TEST_DEVICE_ID, + TEST_DEVICE_NAME, + TEST_HOST, + TEST_PASSWORD, + TEST_PORT, + TEST_USERNAME, +) + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test we get the form and can create entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_DEVICE_NAME + assert result["result"].unique_id == TEST_DEVICE_ID + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + } + + # Verify HikCamera was called with the ssl parameter + mock_hikcamera.assert_called_once_with( + f"http://{TEST_HOST}", TEST_PORT, TEST_USERNAME, TEST_PASSWORD, False + ) + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test we handle cannot connect error and can recover.""" + mock_hikcamera.return_value.get_id = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover from error + mock_hikcamera.return_value.get_id = TEST_DEVICE_ID + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == TEST_DEVICE_ID + + +async def test_form_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test we handle exception during connection and can recover.""" + mock_hikcamera.side_effect = requests.exceptions.RequestException( + "Connection failed" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover from error + mock_hikcamera.side_effect = None + mock_hikcamera.return_value.get_id = TEST_DEVICE_ID + mock_hikcamera.return_value.get_name = TEST_DEVICE_NAME + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == TEST_DEVICE_ID + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured devices.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test YAML import flow creates config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_DEVICE_NAME + assert result["result"].unique_id == TEST_DEVICE_ID + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + } + + # Verify HikCamera was called with the ssl parameter + mock_hikcamera.assert_called_once_with( + f"http://{TEST_HOST}", TEST_PORT, TEST_USERNAME, TEST_PASSWORD, False + ) + + +async def test_import_flow_with_defaults( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test YAML import flow uses default values.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == TEST_DEVICE_ID + # Default port (80) and SSL (False) should be used + assert result["data"][CONF_PORT] == 80 + assert result["data"][CONF_SSL] is False + + +async def test_import_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test YAML import flow aborts on connection error.""" + mock_hikcamera.side_effect = requests.exceptions.RequestException( + "Connection failed" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_flow_no_device_id( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test YAML import flow aborts when device_id is None.""" + mock_hikcamera.return_value.get_id = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_flow_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test YAML import flow aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_with_ssl( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hikcamera: MagicMock, +) -> None: + """Test user flow with ssl enabled passes ssl parameter to HikCamera.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SSL] is True + + # Verify HikCamera was called with ssl=True + mock_hikcamera.assert_called_once_with( + f"https://{TEST_HOST}", TEST_PORT, TEST_USERNAME, TEST_PASSWORD, True + ) diff --git a/tests/components/hikvision/test_init.py b/tests/components/hikvision/test_init.py new file mode 100644 index 00000000000..389fbf71183 --- /dev/null +++ b/tests/components/hikvision/test_init.py @@ -0,0 +1,91 @@ +"""Test Hikvision integration setup and unload.""" + +from unittest.mock import MagicMock + +import requests + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_SSL +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import TEST_HOST, TEST_PASSWORD, TEST_PORT, TEST_USERNAME + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test successful setup and unload of config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_hikcamera.return_value.start_stream.assert_called_once() + + # Verify HikCamera was called with the ssl parameter + mock_hikcamera.assert_called_once_with( + f"http://{TEST_HOST}", TEST_PORT, TEST_USERNAME, TEST_PASSWORD, False + ) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_hikcamera.return_value.disconnect.assert_called_once() + + +async def test_setup_entry_with_ssl( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test setup with ssl enabled passes ssl parameter to HikCamera.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, data={**mock_config_entry.data, CONF_SSL: True} + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify HikCamera was called with ssl=True + mock_hikcamera.assert_called_once_with( + f"https://{TEST_HOST}", TEST_PORT, TEST_USERNAME, TEST_PASSWORD, True + ) + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test setup fails on connection error.""" + mock_hikcamera.side_effect = requests.exceptions.RequestException( + "Connection failed" + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_device_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test setup fails when device_id is None.""" + mock_hikcamera.return_value.get_id = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index 5b0756f6c61..7b2ee47215e 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -273,7 +273,7 @@ async def test_config_flow_preview_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] is None @@ -293,7 +293,7 @@ async def test_config_flow_preview_success( }, ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" assert result["errors"] is None assert result["preview"] == "history_stats" @@ -395,7 +395,7 @@ async def test_options_flow_preview( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "history_stats" @@ -470,7 +470,7 @@ async def test_options_flow_preview_errors( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "history_stats" @@ -554,7 +554,7 @@ async def test_options_flow_sensor_preview_config_entry_removed( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "history_stats" diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index bf5667617c7..936308c9784 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -108,7 +108,7 @@ async def test_config_flow_thread_addon_info_fails( ) # Cannot get addon info - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_info_failed" assert result["description_placeholders"] == { "model": TEST_HARDWARE_NAME, @@ -159,7 +159,7 @@ async def test_config_flow_thread_addon_install_fails( ) # Cannot install addon - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_install_failed" assert result["description_placeholders"] == { "model": TEST_HARDWARE_NAME, @@ -206,7 +206,7 @@ async def test_config_flow_thread_addon_set_config_fails( ), ) - assert pick_thread_progress_result["type"] == FlowResultType.ABORT + assert pick_thread_progress_result["type"] is FlowResultType.ABORT assert pick_thread_progress_result["reason"] == "addon_set_config_failed" assert pick_thread_progress_result["description_placeholders"] == { "model": TEST_HARDWARE_NAME, @@ -252,7 +252,7 @@ async def test_config_flow_thread_flasher_run_fails( ), ) - assert pick_thread_progress_result["type"] == FlowResultType.ABORT + assert pick_thread_progress_result["type"] is FlowResultType.ABORT assert pick_thread_progress_result["reason"] == "addon_start_failed" assert pick_thread_progress_result["description_placeholders"] == { "model": TEST_HARDWARE_NAME, @@ -434,7 +434,7 @@ async def test_options_flow_zigbee_to_thread_zha_configured( user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "zha_still_using_stick" assert result["description_placeholders"] == { "model": TEST_HARDWARE_NAME, @@ -486,7 +486,7 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "otbr_still_using_stick" assert result["description_placeholders"] == { "model": TEST_HARDWARE_NAME, diff --git a/tests/components/homee/test_config_flow.py b/tests/components/homee/test_config_flow.py index 3d2195443a2..e56294d9092 100644 --- a/tests/components/homee/test_config_flow.py +++ b/tests/components/homee/test_config_flow.py @@ -95,7 +95,7 @@ async def test_config_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM flow_id = result["flow_id"] mock_homee.get_access_token.side_effect = side_eff @@ -108,7 +108,7 @@ async def test_config_flow_errors( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == error mock_homee.get_access_token.side_effect = None @@ -122,7 +122,7 @@ async def test_config_flow_errors( }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY @pytest.mark.usefixtures("mock_homee") @@ -237,7 +237,7 @@ async def test_zeroconf_confirm_errors( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == error mock_homee.get_access_token.side_effect = None @@ -249,7 +249,7 @@ async def test_zeroconf_confirm_errors( }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_zeroconf_already_configured( diff --git a/tests/components/homewizard/fixtures/HWE-P1/batteries.json b/tests/components/homewizard/fixtures/HWE-P1/batteries.json index 279e49606b3..05560004fff 100644 --- a/tests/components/homewizard/fixtures/HWE-P1/batteries.json +++ b/tests/components/homewizard/fixtures/HWE-P1/batteries.json @@ -1,5 +1,7 @@ { "mode": "zero", + "permissions": ["charge_allowed", "discharge_allowed"], + "battery_count": 2, "power_w": -404, "target_power_w": -400, "max_consumption_w": 1600, diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 449dfd0c02f..c465608be87 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -279,9 +279,14 @@ dict({ 'data': dict({ 'batteries': dict({ + 'battery_count': 2, 'max_consumption_w': 1600.0, 'max_production_w': 800.0, 'mode': 'zero', + 'permissions': list([ + 'charge_allowed', + 'discharge_allowed', + ]), 'power_w': -404.0, 'target_power_w': -400.0, }), diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index 0797256120c..10898ec527a 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -4,9 +4,9 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Battery group mode', 'options': list([ - , - , - , + 'standby', + 'to_full', + 'zero', ]), }), 'context': , @@ -24,9 +24,9 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - , - , - , + 'standby', + 'to_full', + 'zero', ]), }), 'config_entry_id': , diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index f5c28735da4..69e2bac0a1a 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -61,7 +61,7 @@ async def test_identify_button( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with your HomeWizard Energy device$", + match=r"^An error occurred while communicating with your HomeWizard device$", ): await hass.services.async_call( button.DOMAIN, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ffc31cb3859..1580b2e9c4f 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -73,7 +73,7 @@ async def test_number_entities( mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with your HomeWizard Energy device$", + match=r"^An error occurred while communicating with your HomeWizard device$", ): await hass.services.async_call( number.DOMAIN, diff --git a/tests/components/homewizard/test_select.py b/tests/components/homewizard/test_select.py index d61f8d167c4..c885fcb311d 100644 --- a/tests/components/homewizard/test_select.py +++ b/tests/components/homewizard/test_select.py @@ -155,7 +155,7 @@ async def test_select_request_error( mock_homewizardenergy.batteries.side_effect = RequestError with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with your HomeWizard Energy device$", + match=r"^An error occurred while communicating with your HomeWizard device$", ): await hass.services.async_call( SELECT_DOMAIN, diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 9eba571273d..cd608d28bc0 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -149,7 +149,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with your HomeWizard Energy device$", + match=r"^An error occurred while communicating with your HomeWizard device$", ): await hass.services.async_call( switch.DOMAIN, @@ -160,7 +160,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with your HomeWizard Energy device$", + match=r"^An error occurred while communicating with your HomeWizard device$", ): await hass.services.async_call( switch.DOMAIN, diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index dd109d5ad5e..1415b8c10ff 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -365,7 +365,7 @@ async def test_ssdp( assert result["data_schema"] is not None assert result["data_schema"]({})[CONF_URL] == url + "/" - if result["type"] == FlowResultType.ABORT: + if result["type"] is FlowResultType.ABORT: return login_requests_mock.request( @@ -379,7 +379,7 @@ async def test_ssdp( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == service_info.upnp[ATTR_UPNP_MODEL_NAME] assert result["result"].data[CONF_UPNP_UDN] == service_info.upnp[ATTR_UPNP_UDN] diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 9fb291c57b4..078c560d126 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -254,7 +254,7 @@ async def setup_bridge( with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED async def setup_platform( diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index dca47035784..b493d982155 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Callable -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from bleak.exc import BleakError from improv_ble_client import ( @@ -294,8 +294,13 @@ async def test_bluetooth_rediscovery_after_successful_provision( assert result["step_id"] == "bluetooth_confirm" # Start provisioning - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -375,8 +380,13 @@ async def _test_common_success_with_identify( hass: HomeAssistant, result: FlowResult, address: str ) -> None: """Test bluetooth and user flow success paths.""" - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=True, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -420,8 +430,13 @@ async def _test_common_success_wo_identify( placeholders: dict[str, str] | None = None, ) -> None: """Test bluetooth and user flow success paths.""" - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -475,8 +490,13 @@ async def _test_common_success_wo_identify_w_authorize( hass: HomeAssistant, result: FlowResult, address: str ) -> None: """Test bluetooth and user flow success paths.""" - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -571,7 +591,7 @@ async def test_bluetooth_step_already_in_progress(hass: HomeAssistant) -> None: (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), ], ) -async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None: +async def test_ensure_connected_fails(hass: HomeAssistant, exc, error) -> None: """Test bluetooth flow with error.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -588,7 +608,8 @@ async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None: assert result["errors"] is None with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", side_effect=exc + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected", + side_effect=exc, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -622,8 +643,13 @@ async def test_identify_fails(hass: HomeAssistant, exc, error) -> None: assert result["step_id"] == "bluetooth_confirm" assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=True, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -665,8 +691,13 @@ async def test_need_authorization_fails(hass: HomeAssistant, exc, error) -> None assert result["step_id"] == "bluetooth_confirm" assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -709,8 +740,13 @@ async def test_authorize_fails(hass: HomeAssistant, exc, error) -> None: assert result["step_id"] == "bluetooth_confirm" assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -752,8 +788,13 @@ async def _test_provision_error(hass: HomeAssistant, exc) -> str: assert result["step_id"] == "bluetooth_confirm" assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -878,8 +919,13 @@ async def test_flow_chaining_with_next_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "bluetooth_confirm" # Start provisioning - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -946,8 +992,13 @@ async def test_flow_chaining_timeout(hass: HomeAssistant) -> None: assert result["step_id"] == "bluetooth_confirm" # Start provisioning - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -998,8 +1049,13 @@ async def test_flow_chaining_with_redirect_url(hass: HomeAssistant) -> None: assert result["step_id"] == "bluetooth_confirm" # Start provisioning - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1069,8 +1125,13 @@ async def test_flow_chaining_future_already_done( assert result["step_id"] == "bluetooth_confirm" # Start provisioning - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/inels/test_config_flow.py b/tests/components/inels/test_config_flow.py index 921d12b7d57..c3f024e406f 100644 --- a/tests/components/inels/test_config_flow.py +++ b/tests/components/inels/test_config_flow.py @@ -44,7 +44,7 @@ async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> N result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE assert result["result"].data == {} diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 49ce6b91e96..7ce4724ce3a 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -24,7 +24,7 @@ async def test_standard_config_with_single_fireplace( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "cloud_api" @@ -33,7 +33,7 @@ async def test_standard_config_with_single_fireplace( {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) # For a single fireplace we just create it - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "ip_address": "192.168.2.108", "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", @@ -59,7 +59,7 @@ async def test_standard_config_with_pre_configured_fireplace( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "cloud_api" @@ -69,7 +69,7 @@ async def test_standard_config_with_pre_configured_fireplace( ) # For a single fireplace we just create it - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_devices" @@ -98,7 +98,7 @@ async def test_standard_config_with_single_fireplace_and_bad_credentials( # Erase the error mock_cloud_interface.login_with_credentials.side_effect = None - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "api_error"} assert result["step_id"] == "cloud_api" result = await hass.config_entries.flow.async_configure( @@ -106,7 +106,7 @@ async def test_standard_config_with_single_fireplace_and_bad_credentials( {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) # For a single fireplace we just create it - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "ip_address": "192.168.2.108", "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", @@ -128,7 +128,7 @@ async def test_standard_config_with_multiple_fireplace( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "cloud_api" @@ -137,13 +137,13 @@ async def test_standard_config_with_multiple_fireplace( {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) # When we have multiple fireplaces we get to pick a serial - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pick_cloud_device" result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_SERIAL: "4GC295860E5837G40D9974B7FD459234"}, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "ip_address": "192.168.2.109", "api_key": "D4C5EB28BBFF41E1FB21AFF9BFA6CD34", @@ -172,14 +172,14 @@ async def test_dhcp_discovery_intellifire_device( hostname="zentrios-Test", ), ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "cloud_api" result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_dhcp_discovery_non_intellifire_device( @@ -202,7 +202,7 @@ async def test_dhcp_discovery_non_intellifire_device( hostname="zentrios-Evil", ), ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_intellifire_device" # Test is finished - the DHCP scanner detected a hostname that "might" be an IntelliFire device, but it was not. @@ -217,7 +217,7 @@ async def test_reauth_flow( mock_config_entry_current.add_to_hass(hass) result = await mock_config_entry_current.start_reauth_flow(hass) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result["step_id"] = "cloud_api" result = await hass.config_entries.flow.async_configure( @@ -225,5 +225,5 @@ async def test_reauth_flow( {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/iotty/test_config_flow.py b/tests/components/iotty/test_config_flow.py index 83fa16ece56..4fe32fd133c 100644 --- a/tests/components/iotty/test_config_flow.py +++ b/tests/components/iotty/test_config_flow.py @@ -50,7 +50,7 @@ async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "missing_credentials" @@ -71,7 +71,7 @@ async def test_full_flow( DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} ) - assert result.get("type") == FlowResultType.EXTERNAL_STEP + assert result.get("type") is FlowResultType.EXTERNAL_STEP state = config_entry_oauth2_flow._encode_jwt( hass, diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index fd9d3b1d773..dfcfc18cd54 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -441,7 +441,7 @@ async def test_options_flow( assert config_entry.options == {} result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" # Audio Codec @@ -449,7 +449,7 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert CONF_AUDIO_CODEC not in config_entry.options # Bad @@ -479,5 +479,5 @@ async def test_setting_codec( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_AUDIO_CODEC: codec} ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options[CONF_AUDIO_CODEC] == codec diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py index ed1a9981906..17792f7a27e 100644 --- a/tests/components/kaleidescape/test_init.py +++ b/tests/components/kaleidescape/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -45,6 +46,21 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_disconnect_on_hass_stop( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test device disconnects when Home Assistant stops.""" + assert mock_integration.state is ConfigEntryState.LOADED + assert mock_device.disconnect.call_count == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert mock_device.disconnect.call_count == 1 + + @pytest.mark.usefixtures("mock_device", "mock_integration") async def test_device(device_registry: dr.DeviceRegistry) -> None: """Test device.""" diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 576fce802c0..357d859cdd6 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -11,9 +11,15 @@ from xknx import XKNX from xknx.core import XknxConnectionState, XknxConnectionType from xknx.dpt import DPTArray, DPTBinary from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT -from xknx.telegram import Telegram, TelegramDirection +from xknx.telegram import Telegram, TelegramDirection, tpci from xknx.telegram.address import GroupAddress, IndividualAddress -from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx.telegram.apci import ( + APCI, + GroupValueRead, + GroupValueResponse, + GroupValueWrite, + SecureAPDU, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -82,9 +88,6 @@ class KNXTestKit: async def patch_xknx_start(): """Patch `xknx.start` for unittests.""" - self.xknx.cemi_handler.send_telegram = AsyncMock( - side_effect=self._outgoing_telegrams.append - ) # after XKNX.__init__() to not overwrite it by the config entry again # before StateUpdater starts to avoid slow down of tests self.xknx.rate_limit = 0 @@ -118,13 +121,18 @@ class KNXTestKit: if add_entry_to_hass: self.mock_config_entry.add_to_hass(self.hass) + # capture outgoing telegrams for assertion instead of sending to socket + # before l_data_confirmation would be awaited in xknx + patch( + "xknx.cemi.cemi_handler.CEMIHandler.send_telegram", + side_effect=self._outgoing_telegrams.append, + ).start() # keep patched for the whole test run + knx_config = {DOMAIN: yaml_config or {}} - with ( - patch( - "xknx.xknx.knx_interface_factory", - return_value=knx_ip_interface_mock(), - side_effect=fish_xknx, - ), + with patch( + "xknx.xknx.knx_interface_factory", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, ): state_updater_patcher = patch( "xknx.xknx.StateUpdater.register_remote_value" @@ -134,7 +142,7 @@ class KNXTestKit: await async_setup_component(self.hass, DOMAIN, knx_config) await self.hass.async_block_till_done() - + # remove patch after setup so state_updater can be tested state_updater_patcher.stop() ######################## @@ -312,6 +320,23 @@ class KNXTestKit: source=source, ) + def receive_data_secure_issue( + self, + group_address: str, + source: str | None = None, + ) -> None: + """Inject incoming telegram with undecodable data secure payload.""" + telegram = Telegram( + destination_address=GroupAddress(group_address), + direction=TelegramDirection.INCOMING, + source_address=IndividualAddress(source or self.INDIVIDUAL_ADDRESS), + tpci=tpci.TDataGroup(), + payload=SecureAPDU.from_knx( + bytes.fromhex("03f110002446cfef4ac085e7092ab062b44d") + ), + ) + self.xknx.telegram_queue.received_data_secure_group_key_issue(telegram) + @pytest.fixture def mock_config_entry() -> MockConfigEntry: diff --git a/tests/components/knx/fixtures/config_store_fan.json b/tests/components/knx/fixtures/config_store_fan.json new file mode 100644 index 00000000000..2110ec7f981 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_fan.json @@ -0,0 +1,51 @@ +{ + "version": 2, + "minor_version": 2, + "key": "knx/config_store.json", + "data": { + "entities": { + "fan": { + "knx_es_01KCK9VB3YE1DZ7X4GDHB8BS05": { + "entity": { + "name": "test_step_oscillate", + "device_info": null, + "entity_category": null + }, + "knx": { + "speed": { + "ga_step": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "max_step": 4.0 + }, + "sync_state": true, + "ga_oscillation": { + "write": "1/2/1", + "state": "1/2/0", + "passive": [] + } + } + }, + "knx_es_01KCK9XHXYBG6AP3CNXV4QX2FW": { + "entity": { + "name": "test_percent", + "device_info": null, + "entity_category": null + }, + "knx": { + "speed": { + "ga_speed": { + "write": "2/2/2", + "state": "2/2/0", + "passive": [] + } + }, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 1896c958877..debcdfffb20 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1033,6 +1033,118 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[fan] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'speed', + 'required': True, + 'schema': list([ + dict({ + 'schema': list([ + dict({ + 'name': 'ga_speed', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'percentage_mode', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'name': 'ga_step', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 10, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': 3, + 'name': 'max_step', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 100.0, + 'min': 1.0, + 'mode': 'box', + 'step': 1.0, + }), + }), + 'type': 'ha_selector', + }), + ]), + 'translation_key': 'step_mode', + 'type': 'knx_group_select_option', + }), + ]), + 'type': 'knx_group_select', + }), + dict({ + 'name': 'ga_oscillation', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[light] dict({ 'id': 1, diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index 39cb851af51..a97214d55cf 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -1,10 +1,15 @@ """Test KNX fan.""" -from homeassistant.components.knx.const import KNX_ADDRESS +from typing import Any + +import pytest + +from homeassistant.components.knx.const import KNX_ADDRESS, FanConf from homeassistant.components.knx.schema import FanSchema -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit @@ -59,7 +64,7 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None: FanSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - FanSchema.CONF_MAX_STEP: 4, + FanConf.MAX_STEP: 4, } } ) @@ -143,3 +148,70 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.receive_write("2/2/2", False) state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is False + + +@pytest.mark.parametrize( + ("knx_data", "expected_read_response", "expected_state"), + [ + ( + { + "speed": { + "ga_speed": {"write": "1/1/0", "state": "1/1/1"}, + }, + "ga_oscillation": {"write": "2/2/0", "state": "2/2/2"}, + "sync_state": True, + }, + [("1/1/1", (0x55,)), ("2/2/2", True)], + {"state": STATE_ON, "percentage": 33, "oscillating": True}, + ), + ( + { + "speed": { + "ga_step": {"write": "1/1/0", "state": "1/1/1"}, + "max_step": 3, + }, + "sync_state": True, + }, + [("1/1/1", (2,))], + {"state": STATE_ON, "percentage": 66}, + ), + ], +) +async def test_fan_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], + expected_read_response: list[tuple[str, int | tuple[int, ...]]], + expected_state: dict[str, Any], +) -> None: + """Test creating a fan.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.FAN, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + for address, response in expected_read_response: + await knx.assert_read(address, response=response) + knx.assert_state("fan.test", **expected_state) + + +async def test_fan_ui_load(knx: KNXTestKit) -> None: + """Test loading a fan from storage.""" + await knx.setup_integration(config_store_fixture="config_store_fan.json") + + await knx.assert_read("1/1/0", response=(2,), ignore_order=True) # speed step + await knx.assert_read("1/2/0", response=True, ignore_order=True) # oscillation + await knx.assert_read("2/2/0", response=(0xFF,), ignore_order=True) # speed percent + knx.assert_state( + "fan.test_step_oscillate", + STATE_ON, + percentage=50, + oscillating=True, + ) + knx.assert_state( + "fan.test_percent", + STATE_ON, + percentage=100, + ) diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 4de366c69f0..0d804deb333 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -36,6 +36,7 @@ async def test_diagnostic_entities( "sensor.knx_interface_outgoing_telegrams", "sensor.knx_interface_outgoing_telegram_errors", "sensor.knx_interface_telegrams", + "sensor.knx_interface_undecodable_data_secure_telegrams", ): entity = entity_registry.async_get(entity_id) assert entity.entity_category is EntityCategory.DIAGNOSTIC @@ -43,6 +44,7 @@ async def test_diagnostic_entities( for entity_id in ( "sensor.knx_interface_incoming_telegrams", "sensor.knx_interface_outgoing_telegrams", + "sensor.knx_interface_undecodable_data_secure_telegrams", ): entity = entity_registry.async_get(entity_id) assert entity.disabled is True @@ -57,7 +59,7 @@ async def test_diagnostic_entities( async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(events) == 3 # 5 polled sensors - 2 disabled + assert len(events) == 3 # 6 polled sensors - 3 disabled events.clear() for entity_id, test_state in ( @@ -74,7 +76,7 @@ async def test_diagnostic_entities( state=XknxConnectionState.DISCONNECTED ) await hass.async_block_till_done() - assert len(events) == 4 # 3 not always_available + 3 force_update - 2 disabled + assert len(events) == 4 events.clear() knx.xknx.current_address = IndividualAddress("1.1.1") diff --git a/tests/components/knx/test_repairs.py b/tests/components/knx/test_repairs.py new file mode 100644 index 00000000000..54cc3c90e66 --- /dev/null +++ b/tests/components/knx/test_repairs.py @@ -0,0 +1,133 @@ +"""Test repair flows for KNX integration.""" + +import pytest +from xknx.exceptions.exception import InvalidSecureConfiguration + +from homeassistant.components.knx import repairs +from homeassistant.components.knx.const import ( + CONF_KNX_KNXKEY_PASSWORD, + DOMAIN, + REPAIR_ISSUE_DATA_SECURE_GROUP_KEY, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from .conftest import KNXTestKit +from .test_config_flow import FIXTURE_UPLOAD_UUID, patch_file_upload + +from tests.components.repairs import ( + async_process_repairs_platforms, + get_repairs, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: + """Test create_fix_flow raises on unknown issue_id.""" + + with pytest.raises(ValueError): + await repairs.async_create_fix_flow(hass, "no_such_issue", None) + + +@pytest.mark.parametrize( + "configured_group_address", + ["1/2/5", "3/4/6"], +) +async def test_data_secure_group_key_issue_only_for_configured_group_address( + hass: HomeAssistant, + knx: KNXTestKit, + configured_group_address: str, +) -> None: + """Test that repair issue is only created for configured group addresses.""" + await knx.setup_integration( + { + "switch": { + "name": "Test Switch", + "address": configured_group_address, + } + } + ) + + issue_registry = ir.async_get(hass) + assert bool(issue_registry.issues) is False + # An issue should only be created if this address is configured. + knx.receive_data_secure_issue("1/2/5") + assert bool(issue_registry.issues) is (configured_group_address == "1/2/5") + + +async def test_data_secure_group_key_issue_repair_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + knx: KNXTestKit, +) -> None: + """Test repair flow for DataSecure group key issue.""" + await knx.setup_integration( + { + "switch": [ + {"name": "Test 1", "address": "1/2/5"}, + {"name": "Test 2", "address": "11/0/0"}, + ] + } + ) + + knx.receive_data_secure_issue("11/0/0", source="1.0.1") + knx.receive_data_secure_issue("1/2/5", source="1.0.10") + knx.receive_data_secure_issue("1/2/5", source="1.0.1") + _placeholders = { + "addresses": "`1/2/5` from 1.0.1, 1.0.10\n`11/0/0` from 1.0.1", # check sorting + "interface": "0.0.0", + } + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY) + assert issue is not None + assert issue.translation_placeholders == _placeholders + + issues = await get_repairs(hass, hass_ws_client) + assert issues + + await async_process_repairs_platforms(hass) + client = await hass_client() + flow = await start_repair_fix_flow( + client, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY + ) + + flow_id = flow["flow_id"] + assert flow["type"] == FlowResultType.FORM + assert flow["step_id"] == "secure_knxkeys" + assert flow["description_placeholders"] == _placeholders + + # test error handling + with patch_file_upload( + side_effect=InvalidSecureConfiguration(), + ): + flow = await process_repair_fix_flow( + client, + flow_id, + { + repairs.CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "invalid_password_mocked", + }, + ) + assert flow["type"] == FlowResultType.FORM + assert flow["step_id"] == "secure_knxkeys" + assert flow["errors"] == {CONF_KNX_KNXKEY_PASSWORD: "keyfile_invalid_signature"} + + # test successful file upload + with patch_file_upload(): + flow = await process_repair_fix_flow( + client, + flow_id, + { + repairs.CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "password", + }, + ) + assert flow["type"] == FlowResultType.CREATE_ENTRY + assert ( + issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY) + is None + ) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 3f8f9f0da6c..5bdcfc989db 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -311,6 +311,8 @@ async def test_knx_subscribe_telegrams_command_no_project( "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True ) await knx.assert_write("1/2/4", 1) + # receive undecodable data secure telegram + knx.receive_data_secure_issue("1/2/5") # receive events res = await client.receive_json() @@ -355,6 +357,14 @@ async def test_knx_subscribe_telegrams_command_no_project( assert res["event"]["direction"] == "Outgoing" assert res["event"]["timestamp"] is not None + res = await client.receive_json() + assert res["event"]["destination"] == "1/2/5" + assert res["event"]["payload"] is None + assert res["event"]["telegramtype"] == "SecureAPDU" + assert res["event"]["source"] == "1.2.3" + assert res["event"]["direction"] == "Incoming" + assert res["event"]["timestamp"] is not None + async def test_knx_subscribe_telegrams_command_project( hass: HomeAssistant, diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json index a5a285800e9..cf7b1ebb201 100644 --- a/tests/components/lamarzocco/fixtures/config_mini.json +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -163,7 +163,7 @@ "code": "CMBrewByWeightDoses", "index": 1, "output": { - "scaleConnected": false, + "scaleConnected": true, "availableModes": ["Continuous"], "mode": "Continuous", "doses": { diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 72de5e84285..32a9cc613a8 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -1,4 +1,122 @@ # serializer version: 1 +# name: test_brew_by_weight_dose[Linea Mini][entry-dose-1] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 5, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.lm012345_brew_by_weight_dose_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brew by weight Dose 1', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bbw_dose', + 'unique_id': 'LM012345_bbw_dose_1', + 'unit_of_measurement': , + }) +# --- +# name: test_brew_by_weight_dose[Linea Mini][entry-dose-2] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 5, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.lm012345_brew_by_weight_dose_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brew by weight Dose 2', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bbw_dose', + 'unique_id': 'LM012345_bbw_dose_2', + 'unit_of_measurement': , + }) +# --- +# name: test_brew_by_weight_dose[Linea Mini][state-dose-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'LM012345 Brew by weight Dose 1', + 'max': 100, + 'min': 5, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.lm012345_brew_by_weight_dose_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.5', + }) +# --- +# name: test_brew_by_weight_dose[Linea Mini][state-dose-2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'LM012345 Brew by weight Dose 2', + 'max': 100, + 'min': 5, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.lm012345_brew_by_weight_dose_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.5', + }) +# --- # name: test_general_numbers[coffee_target_temperature-94-set_coffee_target_temperature-kwargs0] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 701ce6b1cd2..f8516b4b89a 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_bbw_dose_mode[Linea Mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LM012345 Brew by weight dose mode', + 'options': list([ + 'continuous', + 'dose1', + 'dose2', + ]), + }), + 'context': , + 'entity_id': 'select.lm012345_brew_by_weight_dose_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'continuous', + }) +# --- +# name: test_bbw_dose_mode[Linea Mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'continuous', + 'dose1', + 'dose2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lm012345_brew_by_weight_dose_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brew by weight dose mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bbw_dose_mode', + 'unique_id': 'LM012345_bbw_dose_mode', + 'unit_of_measurement': None, + }) +# --- # name: test_pre_brew_infusion_select[GS3 AV] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/lamarzocco/test_bluetooth.py b/tests/components/lamarzocco/test_bluetooth.py index 5c006becfdf..becbfb1d376 100644 --- a/tests/components/lamarzocco/test_bluetooth.py +++ b/tests/components/lamarzocco/test_bluetooth.py @@ -10,7 +10,7 @@ from pylamarzocco.exceptions import BluetoothConnectionFailed, RequestNotSuccess import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.components.lamarzocco.const import CONF_OFFLINE_MODE, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, @@ -299,6 +299,71 @@ async def test_setup_through_bluetooth_only( ) +async def test_manual_offline_mode_no_bluetooth_device( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry_bluetooth: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test manual offline mode with no Bluetooth device found.""" + + mock_config_entry_bluetooth.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry_bluetooth, options={CONF_OFFLINE_MODE: True} + ) + await hass.config_entries.async_setup(mock_config_entry_bluetooth.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_bluetooth.state is ConfigEntryState.SETUP_RETRY + + +async def test_manual_offline_mode( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry_bluetooth: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_ble_device_from_address: MagicMock, +) -> None: + """Test that manual offline mode successfully sets up and updates entities via Bluetooth, and marks non-Bluetooth entities as unavailable.""" + + mock_config_entry_bluetooth.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry_bluetooth, options={CONF_OFFLINE_MODE: True} + ) + await hass.config_entries.async_setup(mock_config_entry_bluetooth.entry_id) + await hass.async_block_till_done() + + main_switch = f"switch.{mock_lamarzocco.serial_number}" + state = hass.states.get(main_switch) + assert state + assert state.state == STATE_ON + + # Simulate Bluetooth update changing machine mode to standby + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].mode = MachineMode.STANDBY + + # Trigger Bluetooth coordinator update + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify entity state was updated + state = hass.states.get(main_switch) + assert state + assert state.state == STATE_OFF + + # verify other entities are unavailable + sample_entities = ( + f"binary_sensor.{mock_lamarzocco.serial_number}_backflush_active", + f"update.{mock_lamarzocco.serial_number}_gateway_firmware", + ) + for entity_id in sample_entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + @pytest.mark.parametrize( ("mock_ble_device", "has_client"), [ diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 5d0a514b793..b4958439821 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import ( CONF_INSTALLATION_KEY, + CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN, ) @@ -522,4 +523,47 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_USE_BLUETOOTH: False, + CONF_OFFLINE_MODE: False, + } + + +async def test_options_flow_bluetooth_required_for_offline_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow validates that Bluetooth is required when offline mode is enabled.""" + await async_init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USE_BLUETOOTH: False, + CONF_OFFLINE_MODE: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {CONF_USE_BLUETOOTH: "bluetooth_required_offline"} + + # recover + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USE_BLUETOOTH: True, + CONF_OFFLINE_MODE: True, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_USE_BLUETOOTH: True, + CONF_OFFLINE_MODE: True, } diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 111a306abd1..f73ca211f2a 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import MagicMock from pylamarzocco.const import ( + DoseMode, ModelName, PreExtractionMode, SmartStandByType, @@ -27,6 +28,11 @@ from . import async_init_integration from tests.common import MockConfigEntry +DOSE_MODE_HA_TO_LM = { + "dose1": DoseMode.DOSE_1, + "dose2": DoseMode.DOSE_2, +} + @pytest.mark.parametrize( ("entity_name", "value", "func_name", "kwargs"), @@ -291,3 +297,45 @@ async def test_steam_temperature( mock_lamarzocco.set_steam_target_temperature.assert_called_once_with( temperature=128.3, ) + + +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MINI]) +async def test_brew_by_weight_dose( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test brew by weight dose.""" + + await async_init_integration(hass, mock_config_entry) + serial_number = mock_lamarzocco.serial_number + for dose in (1, 2): + entity_id = f"number.{serial_number}_brew_by_weight_dose_{dose}" + + state = hass.states.get(entity_id) + + assert state + assert state == snapshot(name=f"state-dose-{dose}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"entry-dose-{dose}") + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + mock_lamarzocco.set_brew_by_weight_dose.assert_called_with( + dose=DOSE_MODE_HA_TO_LM[f"dose{dose}"], + value=42, + ) diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 845eda69d5b..97df0219294 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from pylamarzocco.const import ( + DoseMode, ModelName, PreExtractionMode, SmartStandByType, @@ -193,3 +194,40 @@ async def test_select_errors( blocking=True, ) assert exc_info.value.translation_key == "select_option_error" + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MINI]) +async def test_bbw_dose_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco Brew By Weight Mode Select (only for Mini R Models).""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"select.{serial_number}_brew_by_weight_dose_mode") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + # on/off service calls + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.{serial_number}_brew_by_weight_dose_mode", + ATTR_OPTION: "dose2", + }, + blocking=True, + ) + + mock_lamarzocco.set_brew_by_weight_dose_mode.assert_called_once_with( + mode=DoseMode.DOSE_2 + ) diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index c9ad1e528a5..75df96d30b0 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -33,7 +33,9 @@ def laundrify_sensor_fixture() -> LaundrifyDevice: @pytest.fixture(name="laundrify_config_entry") async def laundrify_setup_config_entry( - hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN + hass: HomeAssistant, + laundrify_api_mock, + access_token: str = VALID_ACCESS_TOKEN, ) -> MockConfigEntry: """Create laundrify entry in Home Assistant.""" entry = MockConfigEntry( diff --git a/tests/components/laundrify/test_coordinator.py b/tests/components/laundrify/test_coordinator.py index 64b486d1285..a8e460da506 100644 --- a/tests/components/laundrify/test_coordinator.py +++ b/tests/components/laundrify/test_coordinator.py @@ -3,26 +3,24 @@ from datetime import timedelta from freezegun.api import FrozenDateTimeFactory -from laundrify_aio import LaundrifyDevice, exceptions +from laundrify_aio import exceptions -from homeassistant.components.laundrify.const import DEFAULT_POLL_INTERVAL +from homeassistant.components.laundrify.const import DEFAULT_POLL_INTERVAL, DOMAIN +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, State -from homeassistant.util import slugify +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import async_fire_time_changed - -def get_coord_entity(hass: HomeAssistant, mock_device: LaundrifyDevice) -> State: - """Get the coordinated energy sensor entity.""" - device_slug = slugify(mock_device.name, separator="_") - return hass.states.get(f"sensor.{device_slug}_energy") +# Device ID from fixtures/machines.json +DEVICE_ID = "14" async def test_coordinator_update_success( hass: HomeAssistant, + entity_registry: er.EntityRegistry, laundrify_config_entry, - mock_device: LaundrifyDevice, freezer: FrozenDateTimeFactory, ) -> None: """Test the coordinator update is performed successfully.""" @@ -30,15 +28,19 @@ async def test_coordinator_update_success( async_fire_time_changed(hass) await hass.async_block_till_done() - coord_entity = get_coord_entity(hass, mock_device) - assert coord_entity.state != STATE_UNAVAILABLE + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID}_{SensorDeviceClass.ENERGY}" + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE async def test_coordinator_update_unauthorized( hass: HomeAssistant, + entity_registry: er.EntityRegistry, laundrify_config_entry, laundrify_api_mock, - mock_device: LaundrifyDevice, freezer: FrozenDateTimeFactory, ) -> None: """Test the coordinator update fails if an UnauthorizedException is thrown.""" @@ -48,15 +50,19 @@ async def test_coordinator_update_unauthorized( async_fire_time_changed(hass) await hass.async_block_till_done() - coord_entity = get_coord_entity(hass, mock_device) - assert coord_entity.state == STATE_UNAVAILABLE + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID}_{SensorDeviceClass.ENERGY}" + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_coordinator_update_connection_failed( hass: HomeAssistant, + entity_registry: er.EntityRegistry, laundrify_config_entry, laundrify_api_mock, - mock_device: LaundrifyDevice, freezer: FrozenDateTimeFactory, ) -> None: """Test the coordinator update fails if an ApiConnectionException is thrown.""" @@ -66,5 +72,9 @@ async def test_coordinator_update_connection_failed( async_fire_time_changed(hass) await hass.async_block_till_done() - coord_entity = get_coord_entity(hass, mock_device) - assert coord_entity.state == STATE_UNAVAILABLE + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID}_{SensorDeviceClass.ENERGY}" + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lektrico/fixtures/get_info.json b/tests/components/lektrico/fixtures/get_info.json index 2b099a666e5..6528ec73d92 100644 --- a/tests/components/lektrico/fixtures/get_info.json +++ b/tests/components/lektrico/fixtures/get_info.json @@ -4,7 +4,7 @@ "instant_power": 0, "session_energy": 0.0, "temperature": 34.5, - "total_charged_energy": 0, + "total_charged_energy": 1.123, "install_current": 6, "current_limit_reason": "installation_current", "voltage_l1": 220.0, diff --git a/tests/components/lektrico/snapshots/test_sensor.ambr b/tests/components/lektrico/snapshots/test_sensor.ambr index 569c6af4c04..1d3796ef437 100644 --- a/tests/components/lektrico/snapshots/test_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_sensor.ambr @@ -267,7 +267,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '1.123', }) # --- # name: test_all_entities[sensor.1p7k_500006_limit_reason-entry] diff --git a/tests/components/london_underground/test_config_flow.py b/tests/components/london_underground/test_config_flow.py index 72324d51c8a..230c9ada57a 100644 --- a/tests/components/london_underground/test_config_flow.py +++ b/tests/components/london_underground/test_config_flow.py @@ -44,7 +44,7 @@ async def test_options( """Test updating options.""" result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( @@ -54,7 +54,7 @@ async def test_options( }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_LINE: ["Bakerloo", "Central"], } diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d80697d7865..d92fca32c3b 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -108,12 +108,15 @@ async def integration_fixture( "haojai_switch", "heiman_motion_sensor_m1", "humidity_sensor", + "ikea_air_quality_monitor", + "ikea_scroll_wheel", "inovelli_vtm30", "laundry_dryer", "leak_sensor", "light_sensor", "microwave_oven", "mock_lock", + "mock_thermostat", "mounted_dimmable_load_control_fixture", "multi_endpoint_light", "occupancy_sensor", diff --git a/tests/components/matter/fixtures/nodes/ikea_air_quality_monitor.json b/tests/components/matter/fixtures/nodes/ikea_air_quality_monitor.json new file mode 100644 index 00000000000..b0d6154b53e --- /dev/null +++ b/tests/components/matter/fixtures/nodes/ikea_air_quality_monitor.json @@ -0,0 +1,457 @@ +{ + "node_id": 37, + "date_commissioned": "2025-12-13T04:06:18.441704", + "last_interview": "2025-12-13T16:38:04.075363", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 56, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "IKEA of Sweden", + "0/40/2": 4476, + "0/40/3": "ALPSTUGA air quality monitor", + "0/40/4": 12289, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 512, + "0/40/8": "P2.0", + "0/40/9": 16777229, + "0/40/10": "1.0.13", + "0/40/11": "20250815", + "0/40/12": "E2495", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "a52ff23493dcc940dc04e368f041603d", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 17, 18, 19, 21, 22, 65528, + 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "gljxW2B9Kg4=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "gljxW2B9Kg4=", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/51/0": [ + { + "0": "Thread Beverage", + "1": true, + "2": null, + "3": null, + "4": "lu6+s4F/Ay0=", + "5": [], + "6": [ + "/TLsawU2e4YAAAD//gBYAA==", + "/TLsawU2e4b9UYKeVzrFXQ==", + "/oAAAAAAAACU7r6zgX8DLQ==", + "/ZBhQdb7AADefTBPm1UI7g==" + ], + "7": 4 + } + ], + "0/51/1": 4, + "0/51/2": 45201, + "0/51/3": 12, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "Thread Beverage", + "0/53/3": 45885, + "0/53/4": 9392522397644302862, + "0/53/5": "QP0y7GsFNnuG", + "0/53/7": [ + { + "0": 5108332922748889228, + "1": 8, + "2": 12288, + "3": 6551, + "4": 8526, + "5": 3, + "6": -62, + "7": -62, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 16021616379630337709, + "1": 8, + "2": 37888, + "3": 101778, + "4": 15078, + "5": 3, + "6": -52, + "7": -53, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 16001855347709553818, + "1": 2, + "2": 49152, + "3": 140736, + "4": 16260, + "5": 3, + "6": -75, + "7": -75, + "8": 36, + "9": 1, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 5108332922748889228, + "1": 12288, + "2": 12, + "3": 37, + "4": 1, + "5": 3, + "6": 3, + "7": 8, + "8": true, + "9": true + }, + { + "0": 0, + "1": 22528, + "2": 22, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + }, + { + "0": 16021616379630337709, + "1": 37888, + "2": 37, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 8, + "8": true, + "9": true + }, + { + "0": 16001855347709553818, + "1": 49152, + "2": 48, + "3": 37, + "4": 1, + "5": 3, + "6": 2, + "7": 2, + "8": true, + "9": true + } + ], + "0/53/9": 1480351908, + "0/53/10": 64, + "0/53/11": 229, + "0/53/12": 171, + "0/53/13": 12, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 0, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 59, 60, 61, 62, 65528, 65529, + 65531, 65532, 65533 + ], + "0/56/0": 818959083000000, + "0/56/1": 2, + "0/56/5": [ + { + "0": -18000, + "1": 0, + "2": "America/New_York" + } + ], + "0/56/6": [ + { + "0": 0, + "1": 0, + "2": 826268400000000 + }, + { + "0": 3600, + "1": 826268400000000, + "2": 846828000000000 + } + ], + "0/56/7": 818941083000000, + "0/56/8": 2, + "0/56/10": 2, + "0/56/11": 2, + "0/56/65532": 1, + "0/56/65533": 2, + "0/56/65528": [3], + "0/56/65529": [0, 2, 4], + "0/56/65531": [0, 1, 5, 6, 7, 8, 10, 11, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRJRgkBwEkCAEwCUEEskUNiKarSR+3135Mgwc2naPlrsWJxFxret5bXgGQdrmJ0io8v2+JIpslfDUBpDy/4oRSlyqhgSRB7ZQiwbInDzcKNQEoARgkAgE2AwQCBAEYMAQUMVgzMhWdbGs0nK+MbNLN2nKN8wEwBRSy2oZlnTK3aNsMgpdYR/5EQKKyUBgwC0CNiVdyFs52UxxcUthhDsTDyxSwUYeoqkidCojw9Rn1TN722pfDigKjQQPw83MUhrbfvAUbivQ9xosSCYSdUq/vGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEdErqHwwFMZPiVAvgjEtLZAfpE+XYfmY7WX8HqhpgM93xrDoN8D2dRyt1h2hh2lfSvHcAfHT1o4Vu6icnfVjSXTcKNQEpARgkAmAwBBSy2oZlnTK3aNsMgpdYR/5EQKKyUDAFFPstFffzVGLhZ2ly4VK53McQIWchGDALQArBE0vrZJE7H/wDc8aH998z9e+EzJFMcJ4qnKpmf3pyp6nm0rDGZ/bQc8Q7ibcJCV8/tCt/t6Fa74adD1Mr4SgY", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BPtZo3L4Y38zWfDa60dGDphOVW+QUtw+9JwS35a2mR3yTf5kq5gROYPc9gY/TPv7Hgkyj4Y2gK/Vt5qlv8Tnpjk=", + "2": 4996, + "3": 133826809, + "4": 3924685382, + "5": "", + "254": 1 + }, + { + "1": "BDJXnqbJDe5E0J6AwUugDyvE6QBqfrqp0G/OwjksLo8KyHJeK3Laz48XETuxBHoUG3wJvZ3RwohOUf+/HWxnpiY=", + "2": 4939, + "3": 2, + "4": 37, + "5": "Home", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQAkAgE3AycU0RTWSftjA0MmFfkI+gcYJgRdUQksJgXdhOotNwYnFNEU1kn7YwNDJhX5CPoHGCQHASQIATAJQQT7WaNy+GN/M1nw2utHRg6YTlVvkFLcPvScEt+Wtpkd8k3+ZKuYETmD3PYGP0z7+x4JMo+GNoCv1beapb/E56Y5Nwo1ASkBGCQCYDAEFFVmyJh7knWqXiyP1h8gZWnXYDFVMAUUVWbImHuSdapeLI/WHyBladdgMVUYMAtAtJPOLDR9A30xgPGLdnb3hizRTgQ2MW+Hb1sYLR2UYcH37gE4ZoXxe0kue821tsmqd+iS0jsnq3fmkmOV72wIhhg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEMleepskN7kTQnoDBS6APK8TpAGp+uqnQb87COSwujwrIcl4rctrPjxcRO7EEehQbfAm9ndHCiE5R/78dbGemJjcKNQEpARgkAmAwBBT7LRX381Ri4WdpcuFSudzHECFnITAFFPstFffzVGLhZ2ly4VK53McQIWchGDALQMUtbKUiv+7cBL8ibi5uCn04rTwEzQ/p+KcTWRVtUzmlkEVxssEjM58mhHb2TzkNav4fjBBbp2hHeDrhdF47UgoY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/65532": 2, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 44, + "1": 1 + } + ], + "1/29/1": [3, 6, 29, 91, 1026, 1029, 1037, 1066], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/91/0": 1, + "1/91/65532": 0, + "1/91/65533": 1, + "1/91/65528": [], + "1/91/65529": [], + "1/91/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/1026/0": 1971, + "1/1026/1": -2000, + "1/1026/2": 7000, + "1/1026/3": 30, + "1/1026/65532": 0, + "1/1026/65533": 4, + "1/1026/65528": [], + "1/1026/65529": [], + "1/1026/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/1029/0": 4621, + "1/1029/1": 0, + "1/1029/2": 10000, + "1/1029/3": 200, + "1/1029/65532": 0, + "1/1029/65533": 3, + "1/1029/65528": [], + "1/1029/65529": [], + "1/1029/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/1037/0": 394.0, + "1/1037/1": 0.0, + "1/1037/2": 5000.0, + "1/1037/7": 40.0, + "1/1037/8": 0, + "1/1037/9": 0, + "1/1037/10": 1, + "1/1037/65532": 3, + "1/1037/65533": 3, + "1/1037/65528": [], + "1/1037/65529": [], + "1/1037/65531": [0, 1, 2, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533], + "1/1066/0": 0.0, + "1/1066/1": 0.0, + "1/1066/2": 1000.0, + "1/1066/7": 5.0, + "1/1066/8": 4, + "1/1066/9": 0, + "1/1066/10": 1, + "1/1066/65532": 3, + "1/1066/65533": 3, + "1/1066/65528": [], + "1/1066/65529": [], + "1/1066/65531": [0, 1, 2, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/ikea_scroll_wheel.json b/tests/components/matter/fixtures/nodes/ikea_scroll_wheel.json new file mode 100644 index 00000000000..560315d6f06 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/ikea_scroll_wheel.json @@ -0,0 +1,814 @@ +{ + "node_id": 2, + "date_commissioned": "2025-12-14T12:43:44.346947", + "last_interview": "2025-12-14T12:43:44.346954", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 22, + "1": 2 + } + ], + "0/29/1": [29, 31, 40, 42, 47, 48, 49, 51, 53, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1, 2, 3, 4, 5, 6, 7, 8, 9], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "IKEA of Sweden", + "0/40/2": 4476, + "0/40/3": "BILRESA scroll wheel", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 512, + "0/40/8": "P2.0", + "0/40/9": 17301511, + "0/40/10": "1.8.7", + "0/40/12": "E2490", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "Primary Battery", + "0/47/11": 2570, + "0/47/12": 84, + "0/47/14": 0, + "0/47/15": false, + "0/47/16": 2, + "0/47/19": "AAA", + "0/47/20": 1, + "0/47/25": 2, + "0/47/31": [], + "0/47/65532": 10, + "0/47/65533": 2, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 19, 20, 25, 31, 65528, 65529, 65531, 65532, + 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "9YrzYBMbxiU=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "9YrzYBMbxiU=", + "0/49/7": null, + "0/49/9": 4, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/51/0": [ + { + "0": "NanoleafThread78", + "1": true, + "2": null, + "3": null, + "4": "ar+57lCd5EM=", + "5": [], + "6": [ + "/Q7W0KQSAAHP7/re+SA1DA==", + "/QDK/h6vAAAAAAD//gBACA==", + "/QDK/h6vAADm1m8xFURFwg==", + "/oAAAAAAAABov7nuUJ3kQw==" + ], + "7": 4 + } + ], + "0/51/1": 3, + "0/51/2": 54, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 15, + "0/53/1": 2, + "0/53/2": "NanoleafThread78", + "0/53/3": 1025, + "0/53/4": 17693221680041412133, + "0/53/5": "QP0Ayv4erwAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 16296657462366265352, + "1": 27, + "2": 16384, + "3": 48845, + "4": 97505, + "5": 3, + "6": -9, + "7": -72, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 16296657462366265352, + "1": 16384, + "2": 16, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 27, + "8": true, + "9": true + } + ], + "0/53/9": 187999286, + "0/53/10": 65, + "0/53/11": 156, + "0/53/12": 182, + "0/53/13": 16, + "0/53/14": 1, + "0/53/15": 1, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 1, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 638, + "0/53/23": 637, + "0/53/24": 1, + "0/53/25": 637, + "0/53/26": 636, + "0/53/27": 1, + "0/53/28": 335, + "0/53/29": 303, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 1, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 128, + "0/53/40": 125, + "0/53/41": 0, + "0/53/42": 113, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 3, + "0/53/49": 12, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAhgkBwEkCAEwCUEEdywXd/Kzxt4T08fmpO5sdRSNRRALl6LXpWknaOTORVlFMsW0vS04JTLK/cHdEUZ85eZ4lEkkYhv+H8dy7zsIBzcKNQEoARgkAgE2AwQCBAEYMAQUZizmpJK8zCw5DY5K/lRzN5kqlWgwBRSwKgIF4IpAQgVIcu07P/mvzppi+xgwC0DnJ40xtdQJzxdFJOdT3+7dFkUY8+gER4AEM3M6SkYZ3/FLYr2DBMSCBwIHpcd9inOMDasFKZaNpa6Ecz49RAZEGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYPvNAb3xE0kB0UtHrGrkDNyIwy5M8E8eAnVu+ZfqrgxwlgKrTvsbZ+22J3YK538AceCutZyw0TCDfZs2ZyT/oTcKNQEpARgkAmAwBBSwKgIF4IpAQgVIcu07P/mvzppi+zAFFE7pZ/d2PGT/JtcoMNUAmiz+BR6RGDALQJodpYmAzdRhwXq2c62Hub7IQTMCOz+MHfuRW+0iBAUax/GDLI584eBnE/KLhjBpnyRT5P0ofsZu/HtGRsvTm7kY", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BAnpa1pJCr5yWnKUF8zGEvJd7r8LE6P8ZgKzdQ5Cf8yTRl3pP4rC3uGn00jTGiHSHvwiWdpftAHpBxRSKwWrILg=", + "2": 24582, + "3": 6918604875663522805, + "4": 781801388143683014, + "5": "", + "254": 1 + }, + { + "1": "BMtm5GKWTVty3hxVHsL/Bznq70nfyttLc2TlkfzmdlDc5Q5s/RXDt/WhDsva2Lnj2M2xHEQhpcVcYJLEn/yn8u0=", + "2": 4939, + "3": 2, + "4": 2, + "5": "Aignera 4/50", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AycUmC/ZpD0VsKoYJgQAgMcwJgUAhl9WNwYnFJgv2aQ9FbCqGCQHASQIATAJQQQJ6WtaSQq+clpylBfMxhLyXe6/CxOj/GYCs3UOQn/Mk0Zd6T+Kwt7hp9NI0xoh0h78IlnaX7QB6QcUUisFqyC4Nwo1ASkBGCQCYDAEFEVFQdCrIXeKQOkkPme6adfCoE5FMAUURUVB0Kshd4pA6SQ+Z7pp18KgTkUYMAtA85wSj6YbqFIktfyHfOdqSnFWWy3Y/i6DYqhZ3zoW1pBpbhNrT6Sh4OGtWsv+41h+rFCrLWHpJjIvhiZeOCuNVhg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEy2bkYpZNW3LeHFUewv8HOervSd/K20tzZOWR/OZ2UNzlDmz9FcO39aEOy9rYuePYzbEcRCGlxVxgksSf/Kfy7TcKNQEpARgkAmAwBBRO6Wf3djxk/ybXKDDVAJos/gUekTAFFE7pZ/d2PGT/JtcoMNUAmiz+BR6RGDALQGGlVvBfPUfvGVnj5zb+SJbeXr16FH3Qgru4P1Y3WtvRllb6CxfvH1dvIUmWwbVVR+DW/0uFnSDYMgAvp+shDgcY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/70/0": 900, + "0/70/1": 1000, + "0/70/2": 5000, + "0/70/3": [], + "0/70/4": 2610959764, + "0/70/5": 2, + "0/70/6": 256, + "0/70/7": "Reset the application", + "0/70/8": 1, + "0/70/65532": 7, + "0/70/65533": 2, + "0/70/65528": [1, 4], + "0/70/65529": [0, 2, 3], + "0/70/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "1/29/1": [3, 29, 59], + "1/29/2": [], + "1/29/3": [], + "1/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "1" + }, + { + "0": null, + "1": 67, + "2": 3 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "rotary" + } + ], + "1/29/65532": 1, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "1/59/0": 2, + "1/59/1": 0, + "1/59/2": 18, + "1/59/65532": 22, + "1/59/65533": 1, + "1/59/65528": [], + "1/59/65529": [], + "1/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "2/3/0": 0, + "2/3/1": 0, + "2/3/65532": 0, + "2/3/65533": 4, + "2/3/65528": [], + "2/3/65529": [0], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "2/29/1": [3, 29, 59], + "2/29/2": [], + "2/29/3": [], + "2/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "1" + }, + { + "0": null, + "1": 67, + "2": 4 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "rotary" + } + ], + "2/29/65532": 1, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "2/59/0": 2, + "2/59/1": 0, + "2/59/2": 18, + "2/59/65532": 22, + "2/59/65533": 1, + "2/59/65528": [], + "2/59/65529": [], + "2/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "3/3/0": 0, + "3/3/1": 0, + "3/3/65532": 0, + "3/3/65533": 4, + "3/3/65528": [], + "3/3/65529": [0], + "3/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "3/29/1": [3, 29, 59], + "3/29/2": [], + "3/29/3": [], + "3/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "1" + }, + { + "0": null, + "1": 67, + "2": 2 + }, + { + "0": null, + "1": 67, + "2": 6 + }, + { + "0": null, + "1": 67, + "2": 5 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "button" + } + ], + "3/29/65532": 1, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "3/59/0": 2, + "3/59/1": 0, + "3/59/2": 3, + "3/59/65532": 30, + "3/59/65533": 1, + "3/59/65528": [], + "3/59/65529": [], + "3/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "4/3/0": 0, + "4/3/1": 0, + "4/3/65532": 0, + "4/3/65533": 4, + "4/3/65528": [], + "4/3/65529": [0], + "4/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "4/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "4/29/1": [3, 29, 59], + "4/29/2": [], + "4/29/3": [], + "4/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "2" + }, + { + "0": null, + "1": 67, + "2": 3 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "rotary" + } + ], + "4/29/65532": 1, + "4/29/65533": 2, + "4/29/65528": [], + "4/29/65529": [], + "4/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "4/59/0": 2, + "4/59/1": 0, + "4/59/2": 18, + "4/59/65532": 22, + "4/59/65533": 1, + "4/59/65528": [], + "4/59/65529": [], + "4/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "5/3/0": 0, + "5/3/1": 0, + "5/3/65532": 0, + "5/3/65533": 4, + "5/3/65528": [], + "5/3/65529": [0], + "5/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "5/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "5/29/1": [3, 29, 59], + "5/29/2": [], + "5/29/3": [], + "5/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "2" + }, + { + "0": null, + "1": 67, + "2": 4 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "rotary" + } + ], + "5/29/65532": 1, + "5/29/65533": 2, + "5/29/65528": [], + "5/29/65529": [], + "5/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "5/59/0": 2, + "5/59/1": 0, + "5/59/2": 18, + "5/59/65532": 22, + "5/59/65533": 1, + "5/59/65528": [], + "5/59/65529": [], + "5/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "6/3/0": 0, + "6/3/1": 0, + "6/3/65532": 0, + "6/3/65533": 4, + "6/3/65528": [], + "6/3/65529": [0], + "6/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "6/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "6/29/1": [3, 29, 59], + "6/29/2": [], + "6/29/3": [], + "6/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "2" + }, + { + "0": null, + "1": 67, + "2": 2 + }, + { + "0": null, + "1": 67, + "2": 6 + }, + { + "0": null, + "1": 67, + "2": 5 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "button" + } + ], + "6/29/65532": 1, + "6/29/65533": 2, + "6/29/65528": [], + "6/29/65529": [], + "6/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "6/59/0": 2, + "6/59/1": 0, + "6/59/2": 3, + "6/59/65532": 30, + "6/59/65533": 1, + "6/59/65528": [], + "6/59/65529": [], + "6/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "7/3/0": 0, + "7/3/1": 0, + "7/3/65532": 0, + "7/3/65533": 4, + "7/3/65528": [], + "7/3/65529": [0], + "7/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "7/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "7/29/1": [3, 29, 59], + "7/29/2": [], + "7/29/3": [], + "7/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "3" + }, + { + "0": null, + "1": 67, + "2": 3 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "rotary" + } + ], + "7/29/65532": 1, + "7/29/65533": 2, + "7/29/65528": [], + "7/29/65529": [], + "7/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "7/59/0": 2, + "7/59/1": 0, + "7/59/2": 18, + "7/59/65532": 22, + "7/59/65533": 1, + "7/59/65528": [], + "7/59/65529": [], + "7/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "8/3/0": 0, + "8/3/1": 0, + "8/3/65532": 0, + "8/3/65533": 4, + "8/3/65528": [], + "8/3/65529": [0], + "8/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "8/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "8/29/1": [3, 29, 59], + "8/29/2": [], + "8/29/3": [], + "8/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "3" + }, + { + "0": null, + "1": 67, + "2": 4 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "rotary" + } + ], + "8/29/65532": 1, + "8/29/65533": 2, + "8/29/65528": [], + "8/29/65529": [], + "8/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "8/59/0": 2, + "8/59/1": 0, + "8/59/2": 18, + "8/59/65532": 22, + "8/59/65533": 1, + "8/59/65528": [], + "8/59/65529": [], + "8/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "9/3/0": 0, + "9/3/1": 0, + "9/3/65532": 0, + "9/3/65533": 4, + "9/3/65528": [], + "9/3/65529": [0], + "9/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "9/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "9/29/1": [3, 29, 59], + "9/29/2": [], + "9/29/3": [], + "9/29/4": [ + { + "0": null, + "1": 8, + "2": 6, + "3": "3" + }, + { + "0": null, + "1": 67, + "2": 2 + }, + { + "0": null, + "1": 67, + "2": 6 + }, + { + "0": null, + "1": 67, + "2": 5 + }, + { + "0": null, + "1": 67, + "2": 8, + "3": "button" + } + ], + "9/29/65532": 1, + "9/29/65533": 2, + "9/29/65528": [], + "9/29/65529": [], + "9/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "9/59/0": 2, + "9/59/1": 0, + "9/59/2": 3, + "9/59/65532": 30, + "9/59/65533": 1, + "9/59/65528": [], + "9/59/65529": [], + "9/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/mock_thermostat.json b/tests/components/matter/fixtures/nodes/mock_thermostat.json new file mode 100644 index 00000000000..12c112bb690 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_thermostat.json @@ -0,0 +1,526 @@ +{ + "node_id": 150, + "date_commissioned": "2025-11-18T06:53:08.679289", + "last_interview": "2025-11-18T06:53:08.679325", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "ZW5zMzM=", + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [5, 2], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRlhgkBwEkCAEwCUEE2p7AKvoklmZUFHB0JFUiCsv5FCm0dmeH35yXz4UUH4HAWUwpbeU+R7hMGbAITM3T1R/mVWYthssdVcPNsfIVcjcKNQEoARgkAgE2AwQCBAEYMAQUQbZ3toX8hpE/FmJz7M6xHTbh6RMwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0DughBITJJHW/pS7o0J6o6FYTe1ufe0vCpaCj3qYeWb/QxLUydUaJQbce5Z3lUcFeHybUa/M9HID+0PRp2Ker3/GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 150, + "5": "ha", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/55/2": 425, + "0/55/3": 61, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/1": true, + "0/55/0": 2, + "0/55/8": 16, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 2, 3, 4, 5, 6, 7, 1, 0, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/12": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 12, 6, 7, 8, 9, 10, 11, 65532, 65533, 65528, 65529, + 65531 + ], + "0/52/0": [ + { + "0": 6163, + "1": "6163" + }, + { + "0": 6162, + "1": "6162" + }, + { + "0": 6161, + "1": "6161" + }, + { + "0": 6160, + "1": "6160" + }, + { + "0": 6159, + "1": "6159" + } + ], + "0/52/1": 545392, + "0/52/2": 650640, + "0/52/3": 650640, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "8mJ0KirG", + "5": ["rBEAAQ=="], + "6": [], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwpaqXN", + "5": ["wKgBxA=="], + "6": [ + "KgEOCgKzOZAcmuLd4EsaUA==", + "KgEOCgKzOZA2wMm9YG06Ag==", + "/oAAAAAAAACluAo+qvkuxw==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/3": 0, + "0/51/4": 0, + "0/51/2": 16, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 8, 3, 4, 2, 65532, 65533, 65528, 65529, 65531], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/43/0": "en-US", + "0/43/1": ["en-US"], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Thermostat", + "0/40/4": 32769, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "29DB8B9DB518F05F", + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 24, 11, 12, 13, 14, 15, 16, + 18, 65532, 65533, 65528, 65529, 65531 + ], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 3, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/30/0": [], + "0/30/65532": 0, + "0/30/65533": 1, + "0/30/65528": [], + "0/30/65529": [], + "0/30/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [ + 49, 65, 63, 62, 60, 55, 54, 52, 51, 50, 48, 43, 40, 31, 30, 29, 3, 42, 45, + 53 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 3, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/3/0": 0, + "0/3/1": 2, + "0/3/65532": 0, + "0/3/65533": 6, + "0/3/65528": [], + "0/3/65529": [0, 64], + "0/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 1, + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": 0, + "0/53/15": 0, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 0, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 0, + "0/53/23": 0, + "0/53/24": 0, + "0/53/25": 0, + "0/53/26": 0, + "0/53/27": 0, + "0/53/28": 0, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 0, + "0/53/40": 0, + "0/53/41": 0, + "0/53/42": 0, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 0, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 3, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65532, 65533, 65528, 65529, 65531 + ], + "1/29/0": [ + { + "0": 769, + "1": 4 + } + ], + "1/29/1": [29, 3, 4, 513, 516], + "1/29/2": [3], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 3, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65532, 65533, 65528, 65529, 65531], + + "1/513/0": 1800, + "1/513/1": 500, + "1/513/3": 700, + "1/513/4": 3000, + "1/513/5": 1600, + "1/513/6": 3200, + "1/513/7": 0, + "1/513/8": 25, + "1/513/16": 0, + "1/513/17": 2600, + "1/513/18": 2000, + "1/513/21": 700, + "1/513/22": 3000, + "1/513/23": 1600, + "1/513/24": 3200, + "1/513/25": 25, + "1/513/26": 0, + "1/513/27": 4, + "1/513/28": 1, + "1/513/30": 4, + "1/513/35": 0, + "1/513/36": 0, + "1/513/37": 0, + "1/513/41": 1, + "1/513/48": 0, + "1/513/49": 150, + "1/513/50": 789004800, + "1/513/72": [ + { + "0": 1, + "1": 1, + "2": 1 + }, + { + "0": 2, + "1": 1, + "2": 1 + }, + { + "0": 3, + "1": 1, + "2": 2 + }, + { + "0": 4, + "1": 1, + "2": 2 + }, + { + "0": 5, + "1": 1, + "2": 2 + }, + { + "0": 254, + "1": 1, + "2": 2 + } + ], + "1/513/73": [ + { + "0": 4, + "1": 1, + "2": 2 + }, + { + "0": 3, + "1": 1, + "2": 2 + } + ], + "1/513/74": 5, + "1/513/78": null, + "1/513/80": [ + { + "0": "AQ==", + "1": 1, + "3": 2500, + "4": 2100, + "5": true + }, + { + "0": "Ag==", + "1": 2, + "3": 2600, + "4": 2000, + "5": true + } + ], + "1/513/82": 0, + "1/513/83": 5, + "1/513/84": [], + "1/513/85": null, + "1/513/86": null, + "1/513/65532": 423, + "1/513/65533": 9, + "1/513/65528": [2, 253], + "1/513/65529": [0, 6, 7, 8, 254], + "1/513/65531": [ + 0, 1, 3, 4, 5, 6, 7, 8, 16, 17, 18, 21, 22, 23, 24, 25, 26, 27, 28, 30, + 35, 36, 37, 41, 48, 49, 50, 72, 73, 74, 78, 80, 82, 83, 84, 85, 86, 65532, + 65533, 65528, 65529, 65531 + ], + "1/516/0": 0, + "1/516/1": 0, + "1/516/65532": 0, + "1/516/65533": 2, + "1/516/65528": [], + "1/516/65529": [], + "1/516/65531": [0, 1, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/speaker.json b/tests/components/matter/fixtures/nodes/speaker.json index f28923b3b3c..a5d3316704d 100644 --- a/tests/components/matter/fixtures/nodes/speaker.json +++ b/tests/components/matter/fixtures/nodes/speaker.json @@ -46,7 +46,7 @@ "0/40/65532": 0, "0/40/0": 19, "0/40/6": "**REDACTED**", - "0/40/1": "Beep Home", + "0/40/1": "TEST_VENDOR", "0/40/2": 65521, "0/40/3": "Mock speaker", "0/40/4": 32768, @@ -54,7 +54,7 @@ "0/40/8": "1.0", "0/40/9": 1, "0/40/10": "1.0", - "0/40/18": "A576929DE6D138DC", + "0/40/18": "123456789", "0/40/19": { "0": 3, "1": 3 @@ -221,6 +221,8 @@ "1/8/65532": 0, "1/8/65533": 6, "1/8/0": 47, + "1/8/2": 0, + "1/8/3": 100, "1/8/17": null, "1/8/15": 0, "1/8/65528": [], diff --git a/tests/components/matter/fixtures/nodes/vacuum_cleaner.json b/tests/components/matter/fixtures/nodes/vacuum_cleaner.json index 69f2e9bff86..4a3630fdbf5 100644 --- a/tests/components/matter/fixtures/nodes/vacuum_cleaner.json +++ b/tests/components/matter/fixtures/nodes/vacuum_cleaner.json @@ -357,7 +357,7 @@ ], "1/336/2": [], "1/336/3": 7, - "1/336/4": 1756501200, + "1/336/4": 809816400, "1/336/5": [], "1/336/65532": 6, "1/336/65533": 1, diff --git a/tests/components/matter/fixtures/nodes/valve.json b/tests/components/matter/fixtures/nodes/valve.json index 5ba06412ca9..16ca08592ae 100644 --- a/tests/components/matter/fixtures/nodes/valve.json +++ b/tests/components/matter/fixtures/nodes/valve.json @@ -239,7 +239,7 @@ "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "1/129/0": 0, "1/129/1": 0, - "1/129/2": 0, + "1/129/2": 789004800000000, "1/129/3": null, "1/129/4": 0, "1/129/5": 0, @@ -248,7 +248,7 @@ "1/129/8": 100, "1/129/9": 0, "1/129/10": 0, - "1/129/65532": 0, + "1/129/65532": 1, "1/129/65533": 1, "1/129/65528": [], "1/129/65529": [0, 1], diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index e1f4c6d6008..8c816c8b705 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -391,6 +391,102 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_local_temperature_remote_sensing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.eve_thermo_local_temperature_remote_sensing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Local temperature remote sensing', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_remote_sensing_local_temperature', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatRemoteSensing_LocalTemperature-513-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_local_temperature_remote_sensing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Local temperature remote sensing', + }), + 'context': , + 'entity_id': 'binary_sensor.eve_thermo_local_temperature_remote_sensing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_outdoor_temperature_remote_sensing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.eve_thermo_outdoor_temperature_remote_sensing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Outdoor temperature remote sensing', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_remote_sensing_outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatRemoteSensing_OutdoorTemperature-513-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_outdoor_temperature_remote_sensing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Outdoor temperature remote sensing', + }), + 'context': , + 'entity_id': 'binary_sensor.eve_thermo_outdoor_temperature_remote_sensing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[heiman_motion_sensor_m1][binary_sensor.smart_motion_sensor_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -636,6 +732,150 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[mock_thermostat][binary_sensor.mock_thermostat_local_temperature_remote_sensing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_thermostat_local_temperature_remote_sensing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Local temperature remote sensing', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_remote_sensing_local_temperature', + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatRemoteSensing_LocalTemperature-513-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[mock_thermostat][binary_sensor.mock_thermostat_local_temperature_remote_sensing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Thermostat Local temperature remote sensing', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_thermostat_local_temperature_remote_sensing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[mock_thermostat][binary_sensor.mock_thermostat_occupancy_remote_sensing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_thermostat_occupancy_remote_sensing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Occupancy remote sensing', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_remote_sensing_occupancy', + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatRemoteSensing_Occupancy-513-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[mock_thermostat][binary_sensor.mock_thermostat_occupancy_remote_sensing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Thermostat Occupancy remote sensing', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_thermostat_occupancy_remote_sensing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[mock_thermostat][binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Outdoor temperature remote sensing', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_remote_sensing_outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatRemoteSensing_OutdoorTemperature-513-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[mock_thermostat][binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Thermostat Outdoor temperature remote sensing', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[occupancy_sensor][binary_sensor.mock_occupancy_sensor_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 711429cd844..766198b8cc8 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1857,6 +1857,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[ikea_air_quality_monitor][button.alpstuga_air_quality_monitor_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.alpstuga_air_quality_monitor_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[ikea_air_quality_monitor][button.alpstuga_air_quality_monitor_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'ALPSTUGA air quality monitor Identify', + }), + 'context': , + 'entity_id': 'button.alpstuga_air_quality_monitor_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[inovelli_vtm30][button.white_series_onoff_switch_identify_load_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2388,6 +2437,104 @@ 'state': 'unknown', }) # --- +# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_thermostat_identify_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (0)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-0-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Mock Thermostat Identify (0)', + }), + 'context': , + 'entity_id': 'button.mock_thermostat_identify_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_thermostat_identify_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Mock Thermostat Identify (1)', + }), + 'context': , + 'entity_id': 'button.mock_thermostat_identify_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[multi_endpoint_light][button.inovelli_identify_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 5b5f9cd3f8d..0538ca3f60b 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -325,6 +325,77 @@ 'state': 'cool', }) # --- +# name: test_climates[mock_thermostat][climate.mock_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climates[mock_thermostat][climate.mock_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.0, + 'friendly_name': 'Mock Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': 26.0, + 'target_temp_low': 20.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.mock_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_climates[room_airconditioner][climate.room_airconditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index bd133a30877..56a6c3c6b0c 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -759,6 +759,627 @@ 'state': 'unknown', }) # --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-1-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (1)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (2)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'long_press', + 'long_release', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-3-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'long_press', + 'long_release', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (3)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (4)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (4)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (5)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-5-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (5)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'long_press', + 'long_release', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (6)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-6-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'long_press', + 'long_release', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (6)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (7)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-7-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (7)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (8)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-8-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'multi_press_4', + 'multi_press_5', + 'multi_press_6', + 'multi_press_7', + 'multi_press_8', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (8)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'long_press', + 'long_release', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bilresa_scroll_wheel_button_9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (9)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-9-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[ikea_scroll_wheel][event.bilresa_scroll_wheel_button_9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'multi_press_1', + 'multi_press_2', + 'multi_press_3', + 'long_press', + 'long_release', + ]), + 'friendly_name': 'BILRESA scroll wheel Button (9)', + }), + 'context': , + 'entity_id': 'event.bilresa_scroll_wheel_button_9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_events[inovelli_vtm30][event.white_series_onoff_switch_button_config-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 770f6544e6c..b836bece869 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -3691,6 +3691,64 @@ 'state': '4.0', }) # --- +# name: test_numbers[speaker][number.mock_speaker_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_speaker_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'speaker_setpoint', + 'unique_id': '00000000000004D2-000000000000006B-MatterNodeDevice-1-speaker_setpoint-8-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[speaker][number.mock_speaker_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock speaker Volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_speaker_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47', + }) +# --- # name: test_numbers[valve][number.valve_default_open_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index f4607984956..126acb631bb 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -241,10 +241,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -253,7 +250,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.aqara_smart_lock_u200_operating_mode', 'has_entity_name': True, 'hidden_by': None, @@ -282,10 +279,7 @@ 'friendly_name': 'Aqara Smart Lock U200 Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -684,10 +678,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -696,7 +687,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.mock_door_lock_operating_mode', 'has_entity_name': True, 'hidden_by': None, @@ -725,10 +716,7 @@ 'friendly_name': 'Mock Door Lock Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -869,10 +857,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -881,7 +866,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.mock_door_lock_with_unbolt_operating_mode', 'has_entity_name': True, 'hidden_by': None, @@ -910,10 +895,7 @@ 'friendly_name': 'Mock Door Lock with unbolt Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -2523,10 +2505,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -2535,7 +2514,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.mock_lock_operating_mode', 'has_entity_name': True, 'hidden_by': None, @@ -2564,10 +2543,7 @@ 'friendly_name': 'Mock Lock Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -2639,6 +2615,63 @@ 'state': 'silent', }) # --- +# name: test_selects[mock_thermostat][select.mock_thermostat_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mock_thermostat_temperature_display_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[mock_thermostat][select.mock_thermostat_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Thermostat Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.mock_thermostat_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3726,10 +3759,8 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -3738,7 +3769,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.secuyou_smart_lock_operating_mode', 'has_entity_name': True, 'hidden_by': None, @@ -3767,10 +3798,8 @@ 'friendly_name': 'Secuyou Smart Lock Operating mode', 'options': list([ 'normal', - 'vacation', 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index d6fbd4c6ef0..f9a2a1fdf08 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6512,6 +6512,906 @@ 'state': '0.0', }) # --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extremely_poor', + 'very_poor', + 'poor', + 'fair', + 'good', + 'moderate', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.alpstuga_air_quality_monitor_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-AirQuality-91-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'ALPSTUGA air quality monitor Air quality', + 'options': list([ + 'extremely_poor', + 'very_poor', + 'poor', + 'fair', + 'good', + 'moderate', + ]), + }), + 'context': , + 'entity_id': 'sensor.alpstuga_air_quality_monitor_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.alpstuga_air_quality_monitor_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-CarbonDioxideSensor-1037-0', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'ALPSTUGA air quality monitor Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.alpstuga_air_quality_monitor_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '394.0', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.alpstuga_air_quality_monitor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-HumiditySensor-1029-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'ALPSTUGA air quality monitor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.alpstuga_air_quality_monitor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46.21', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.alpstuga_air_quality_monitor_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-PM25Sensor-1066-0', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'ALPSTUGA air quality monitor PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.alpstuga_air_quality_monitor_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.alpstuga_air_quality_monitor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[ikea_air_quality_monitor][sensor.alpstuga_air_quality_monitor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'ALPSTUGA air quality monitor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.alpstuga_air_quality_monitor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.71', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-0-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'BILRESA scroll wheel Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Battery type', + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'AAA', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'BILRESA scroll wheel Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.57', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (1)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (2)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-3-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (3)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (4)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (4)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (5)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-5-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (5)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (6)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-6-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (6)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (7)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-7-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (7)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (8)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-8-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (8)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (9)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-9-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ikea_scroll_wheel][sensor.bilresa_scroll_wheel_current_switch_position_9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BILRESA scroll wheel Current switch position (9)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.bilresa_scroll_wheel_current_switch_position_9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[inovelli_vtm30][sensor.white_series_onoff_switch_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7634,6 +8534,167 @@ 'state': '5', }) # --- +# name: test_sensors[mock_thermostat][sensor.mock_thermostat_heating_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_thermostat_heating_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating demand', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pi_heating_demand', + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatPIHeatingDemand-513-8', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[mock_thermostat][sensor.mock_thermostat_heating_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Thermostat Heating demand', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_thermostat_heating_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_thermostat_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Thermostat Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_thermostat_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[mock_thermostat][sensor.mock_thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_thermostat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[mock_thermostat][sensor.mock_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.0', + }) +# --- # name: test_sensors[multi_endpoint_light][sensor.inovelli_current_switch_position_config-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11147,6 +12208,55 @@ 'state': 'stopped', }) # --- +# name: test_sensors[valve][sensor.valve_auto_close_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.valve_auto_close_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto-close time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_close_time', + 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-ValveConfigurationAndControlAutoCloseTime-129-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[valve][sensor.valve_auto_close_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Valve Auto-close time', + }), + 'context': , + 'entity_id': 'sensor.valve_auto_close_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T00:00:00+00:00', + }) +# --- # name: test_sensors[window_covering_full][sensor.mock_full_window_covering_target_opening_position-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 54556906b0d..94da1b87f93 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -535,6 +535,55 @@ 'state': 'off', }) # --- +# name: test_switches[ikea_air_quality_monitor][switch.alpstuga_air_quality_monitor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.alpstuga_air_quality_monitor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-MatterSwitch-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[ikea_air_quality_monitor][switch.alpstuga_air_quality_monitor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'ALPSTUGA air quality monitor', + }), + 'context': , + 'entity_id': 'switch.alpstuga_air_quality_monitor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[inovelli_vtm30][switch.white_series_onoff_switch_switch_load_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index fc988ad068a..5b1a52c96c6 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -435,3 +435,160 @@ async def test_shutter_problem( state = hass.states.get("binary_sensor.eve_shutter_switch_20eci1701_problem") assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["mock_thermostat"]) +async def test_thermostat_remote_sensing( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat remote sensing binary sensors.""" + remote_sensing_attribute = clusters.Thermostat.Attributes.RemoteSensing + + # Test initial state (RemoteSensing = 0, all bits off) + state = hass.states.get( + "binary_sensor.mock_thermostat_local_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get( + "binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.mock_thermostat_occupancy_remote_sensing") + assert state + assert state.state == "off" + + # Set LocalTemperature bit (bit 0) + set_node_attribute( + matter_node, + 1, + remote_sensing_attribute.cluster_id, + remote_sensing_attribute.attribute_id, + 1, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get( + "binary_sensor.mock_thermostat_local_temperature_remote_sensing" + ) + assert state + assert state.state == "on" + + state = hass.states.get( + "binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.mock_thermostat_occupancy_remote_sensing") + assert state + assert state.state == "off" + + # Set OutdoorTemperature bit (bit 1) + set_node_attribute( + matter_node, + 1, + remote_sensing_attribute.cluster_id, + remote_sensing_attribute.attribute_id, + 2, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get( + "binary_sensor.mock_thermostat_local_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get( + "binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing" + ) + assert state + assert state.state == "on" + + state = hass.states.get("binary_sensor.mock_thermostat_occupancy_remote_sensing") + assert state + assert state.state == "off" + + # Set Occupancy bit (bit 2) + set_node_attribute( + matter_node, + 1, + remote_sensing_attribute.cluster_id, + remote_sensing_attribute.attribute_id, + 4, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get( + "binary_sensor.mock_thermostat_local_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get( + "binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.mock_thermostat_occupancy_remote_sensing") + assert state + assert state.state == "on" + + # Set multiple bits (bits 0 and 2 = value 5) + set_node_attribute( + matter_node, + 1, + remote_sensing_attribute.cluster_id, + remote_sensing_attribute.attribute_id, + 5, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get( + "binary_sensor.mock_thermostat_local_temperature_remote_sensing" + ) + assert state + assert state.state == "on" + + state = hass.states.get( + "binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing" + ) + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.mock_thermostat_occupancy_remote_sensing") + assert state + assert state.state == "on" + + # Set all bits (value 7) + set_node_attribute( + matter_node, + 1, + remote_sensing_attribute.cluster_id, + remote_sensing_attribute.attribute_id, + 7, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get( + "binary_sensor.mock_thermostat_local_temperature_remote_sensing" + ) + assert state + assert state.state == "on" + + state = hass.states.get( + "binary_sensor.mock_thermostat_outdoor_temperature_remote_sensing" + ) + assert state + assert state.state == "on" + + state = hass.states.get("binary_sensor.mock_thermostat_occupancy_remote_sensing") + assert state + assert state.state == "on" diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 64fd5a98816..f2280633b4e 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -8,7 +8,6 @@ from matter_server.common.helpers.util import create_attribute_path_from_attribu import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.matter.select import DOOR_LOCK_OPERATING_MODE_MAP from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -314,22 +313,30 @@ async def test_door_lock_operating_mode_select( """Test Door Lock Operating Mode select entity discovery and interaction. Verifies: - - Options match mapping in DOOR_LOCK_OPERATING_MODE_MAP + - Options are filtered based on SupportedOperatingModes bitmap - Attribute updates reflect current option - Selecting an option writes correct enum value """ entity_id = "select.secuyou_smart_lock_operating_mode" state = hass.states.get(entity_id) assert state, "Missing operating mode select entity" - assert state.attributes["options"] == list(DOOR_LOCK_OPERATING_MODE_MAP.values()) - # Initial state should be one of the allowed options + # According to the spec, bit=0 means supported and bit=1 means not supported. + # The fixture bitmap clears bits 0, 2, and 3, so the supported modes are + # Normal, Privacy, and NoRemoteLockUnlock; the other bits are set (not + # supported). + assert set(state.attributes["options"]) == { + "normal", + "privacy", + "no_remote_lock_unlock", + } + # Verify that the initial state is part of the allowed options assert state.state in state.attributes["options"] # Dynamically obtain ids instead of hardcoding door_lock_cluster_id = clusters.DoorLock.Attributes.OperatingMode.cluster_id operating_mode_attr_id = clusters.DoorLock.Attributes.OperatingMode.attribute_id - # Change OperatingMode attribute on the node to 'privacy' + # Change OperatingMode attribute on the node to a supported mode ('privacy') set_node_attribute( matter_node, 1, @@ -341,12 +348,12 @@ async def test_door_lock_operating_mode_select( state = hass.states.get(entity_id) assert state.state == "privacy" - # Select another option (vacation) via service to validate mapping + # Select another supported option (NoRemoteLockUnlock) via service to validate mapping matter_client.write_attribute.reset_mock() await hass.services.async_call( "select", "select_option", - {"entity_id": entity_id, "option": "vacation"}, + {"entity_id": entity_id, "option": "no_remote_lock_unlock"}, blocking=True, ) assert matter_client.write_attribute.call_count == 1 @@ -356,5 +363,5 @@ async def test_door_lock_operating_mode_select( endpoint_id=1, attribute=clusters.DoorLock.Attributes.OperatingMode, ), - value=clusters.DoorLock.Enums.OperatingModeEnum.kVacation, + value=clusters.DoorLock.Enums.OperatingModeEnum.kNoRemoteLockUnlock, ) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index d99c2051332..9a5f01321fd 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -642,7 +642,7 @@ async def test_vacuum_actions( assert state assert state.state == "2025-08-29T21:00:00+00:00" - set_node_attribute(matter_node, 1, 336, 4, 1756502000) + set_node_attribute(matter_node, 1, 336, 4, 809817200) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("sensor.mock_vacuum_estimated_end_time") @@ -732,3 +732,35 @@ async def test_optional_door_event_sensors_from_featuremap( state = hass.states.get(entity_id_closed) assert state assert state.state == "8" + + +@pytest.mark.parametrize("node_fixture", ["valve"]) +async def test_valve( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test valve AutoCloseTime sensor with Matter epoch microseconds conversion.""" + # ValveConfigurationAndControl Cluster / AutoCloseTime attribute (1/129/2) + # Initial value is 789004800000000 microseconds = 2025-01-01 00:00:00 UTC + state = hass.states.get("sensor.valve_auto_close_time") + assert state + assert state.state == "2025-01-01T00:00:00+00:00" + + # Set to another timestamp: 820540800000000 microseconds + # = 820540800 seconds since 2000-01-01 = 1767225600 Unix epoch + # = 2026-01-01 00:00:00 UTC + set_node_attribute(matter_node, 1, 129, 2, 820540800000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.valve_auto_close_time") + assert state + assert state.state == "2026-01-01T00:00:00+00:00" + + # Test setting to 0 (invalid/null) - should result in unknown state + set_node_attribute(matter_node, 1, 129, 2, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.valve_auto_close_time") + assert state + assert state.state == "unknown" diff --git a/tests/components/mcp_server/test_config_flow.py b/tests/components/mcp_server/test_config_flow.py index 52bbc26873c..aac53499922 100644 --- a/tests/components/mcp_server/test_config_flow.py +++ b/tests/components/mcp_server/test_config_flow.py @@ -26,7 +26,7 @@ async def test_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert not result["errors"] result = await hass.config_entries.flow.async_configure( @@ -35,7 +35,7 @@ async def test_form( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Assist" assert len(mock_setup_entry.mock_calls) == 1 assert result["data"] == {CONF_LLM_HASS_API: ["assist"]} @@ -57,7 +57,7 @@ async def test_form_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert not result["errors"] result = await hass.config_entries.flow.async_configure( @@ -66,5 +66,5 @@ async def test_form_errors( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == errors diff --git a/tests/components/mealie/fixtures/about.json b/tests/components/mealie/fixtures/about.json index 1ffac4bdd5a..443e13e7a9a 100644 --- a/tests/components/mealie/fixtures/about.json +++ b/tests/components/mealie/fixtures/about.json @@ -1,3 +1,3 @@ { - "version": "v2.0.0" + "version": "v3.7.0" } diff --git a/tests/components/mealie/fixtures/get_mealplan_today.json b/tests/components/mealie/fixtures/get_mealplan_today.json index 1413f4a0113..634c6fad449 100644 --- a/tests/components/mealie/fixtures/get_mealplan_today.json +++ b/tests/components/mealie/fixtures/get_mealplan_today.json @@ -110,7 +110,7 @@ }, { "date": "2024-01-21", - "entryType": "lunch", + "entryType": "dessert", "title": "", "text": "", "recipeId": "27455eb2-31d3-4682-84ff-02a114bf293a", @@ -178,7 +178,7 @@ }, { "date": "2024-01-21", - "entryType": "dinner", + "entryType": "snack", "title": "", "text": "", "recipeId": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", @@ -218,7 +218,7 @@ }, { "date": "2024-01-21", - "entryType": "dinner", + "entryType": "drink", "title": "", "text": "", "recipeId": "27455eb2-31d3-4682-84ff-02a114bf293a", diff --git a/tests/components/mealie/fixtures/get_mealplans.json b/tests/components/mealie/fixtures/get_mealplans.json index 89584a9c917..c7918ed8e80 100644 --- a/tests/components/mealie/fixtures/get_mealplans.json +++ b/tests/components/mealie/fixtures/get_mealplans.json @@ -256,7 +256,7 @@ }, { "date": "2024-01-23", - "entryType": "dinner", + "entryType": "dessert", "title": "", "text": "", "recipeId": "47595e4c-52bc-441d-b273-3edf4258806d", @@ -500,7 +500,7 @@ }, { "date": "2024-01-22", - "entryType": "dinner", + "entryType": "drink", "title": "", "text": "", "recipeId": "9d553779-607e-471b-acf3-84e6be27b159", @@ -574,7 +574,7 @@ }, { "date": "2024-01-22", - "entryType": "dinner", + "entryType": "snack", "title": "", "text": "", "recipeId": "55c88810-4cf1-4d86-ae50-63b15fd173fb", diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index 48f5aaa7d75..ad1fdcc07bc 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -5,10 +5,18 @@ 'entity_id': 'calendar.mealie_breakfast', 'name': 'Mealie Breakfast', }), + dict({ + 'entity_id': 'calendar.mealie_dessert', + 'name': 'Mealie Dessert', + }), dict({ 'entity_id': 'calendar.mealie_dinner', 'name': 'Mealie Dinner', }), + dict({ + 'entity_id': 'calendar.mealie_drink', + 'name': 'Mealie Drink', + }), dict({ 'entity_id': 'calendar.mealie_lunch', 'name': 'Mealie Lunch', @@ -17,6 +25,10 @@ 'entity_id': 'calendar.mealie_side', 'name': 'Mealie Side', }), + dict({ + 'entity_id': 'calendar.mealie_snack', + 'name': 'Mealie Snack', + }), ]) # --- # name: test_api_events @@ -49,20 +61,6 @@ 'summary': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'uid': None, }), - dict({ - 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', - 'end': dict({ - 'date': '2024-01-24', - }), - 'location': None, - 'recurrence_id': None, - 'rrule': None, - 'start': dict({ - 'date': '2024-01-23', - }), - 'summary': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', - 'uid': None, - }), dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'end': dict({ @@ -105,20 +103,6 @@ 'summary': 'All-American Beef Stew Recipe', 'uid': None, }), - dict({ - 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', - 'end': dict({ - 'date': '2024-01-23', - }), - 'location': None, - 'recurrence_id': None, - 'rrule': None, - 'start': dict({ - 'date': '2024-01-22', - }), - 'summary': 'Einfacher Nudelauflauf mit Brokkoli', - 'uid': None, - }), dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'end': dict({ @@ -133,20 +117,6 @@ 'summary': 'Miso Udon Noodles with Spinach and Tofu', 'uid': None, }), - dict({ - 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', - 'end': dict({ - 'date': '2024-01-23', - }), - 'location': None, - 'recurrence_id': None, - 'rrule': None, - 'start': dict({ - 'date': '2024-01-22', - }), - 'summary': 'Mousse de saumon', - 'uid': None, - }), dict({ 'description': 'Dineren met de boys', 'end': dict({ @@ -217,6 +187,60 @@ 'state': 'off', }) # --- +# name: test_entities[calendar.mealie_dessert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_dessert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dessert', + 'platform': 'mealie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dessert', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_dessert', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_dessert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', + 'end_time': '2024-01-24 00:00:00', + 'friendly_name': 'Mealie Dessert', + 'location': '', + 'message': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', + 'start_time': '2024-01-23 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_dessert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_entities[calendar.mealie_dinner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -271,6 +295,60 @@ 'state': 'off', }) # --- +# name: test_entities[calendar.mealie_drink-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_drink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Drink', + 'platform': 'mealie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'drink', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_drink', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_drink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'end_time': '2024-01-23 00:00:00', + 'friendly_name': 'Mealie Drink', + 'location': '', + 'message': 'Einfacher Nudelauflauf mit Brokkoli', + 'start_time': '2024-01-22 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_drink', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_entities[calendar.mealie_lunch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -379,3 +457,57 @@ 'state': 'off', }) # --- +# name: test_entities[calendar.mealie_snack-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_snack', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snack', + 'platform': 'mealie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snack', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_snack', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_snack-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', + 'end_time': '2024-01-23 00:00:00', + 'friendly_name': 'Mealie Snack', + 'location': '', + 'message': 'Mousse de saumon', + 'start_time': '2024-01-22 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_snack', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index d8927a0963f..b06de79edc0 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -2,7 +2,7 @@ # name: test_entry_diagnostics dict({ 'about': dict({ - 'version': 'v2.0.0', + 'version': 'v3.7.0', }), 'mealplans': dict({ 'breakfast': list([ @@ -36,6 +36,37 @@ 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), ]), + 'dessert': list([ + dict({ + 'description': None, + 'entry_type': 'dessert', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': dict({ + '__type': "", + 'isoformat': '2024-01-23', + }), + 'mealplan_id': 221, + 'recipe': dict({ + 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'Kn62', + 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', + 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', + 'perform_time': '20 Minutes', + 'prep_time': '40 Minutes', + 'rating': None, + 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', + 'recipe_yield': '4 servings', + 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', + 'total_time': '1 Hour', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), 'dinner': list([ dict({ 'description': None, @@ -95,35 +126,6 @@ 'title': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), - dict({ - 'description': None, - 'entry_type': 'dinner', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'mealplan_date': dict({ - '__type': "", - 'isoformat': '2024-01-23', - }), - 'mealplan_id': 221, - 'recipe': dict({ - 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'image': 'Kn62', - 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', - 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', - 'perform_time': '20 Minutes', - 'prep_time': '40 Minutes', - 'rating': None, - 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', - 'recipe_yield': '4 servings', - 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', - 'total_time': '1 Hour', - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), - 'title': None, - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), dict({ 'description': None, 'entry_type': 'dinner', @@ -211,35 +213,6 @@ 'title': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), - dict({ - 'description': None, - 'entry_type': 'dinner', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'mealplan_date': dict({ - '__type': "", - 'isoformat': '2024-01-22', - }), - 'mealplan_id': 211, - 'recipe': dict({ - 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'image': 'nOPT', - 'name': 'Einfacher Nudelauflauf mit Brokkoli', - 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', - 'perform_time': '20 Minutes', - 'prep_time': '15 Minutes', - 'rating': None, - 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', - 'recipe_yield': '4 servings', - 'slug': 'einfacher-nudelauflauf-mit-brokkoli', - 'total_time': '35 Minutes', - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), - 'title': None, - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), dict({ 'description': None, 'entry_type': 'dinner', @@ -269,35 +242,6 @@ 'title': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), - dict({ - 'description': None, - 'entry_type': 'dinner', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'mealplan_date': dict({ - '__type': "", - 'isoformat': '2024-01-22', - }), - 'mealplan_id': 195, - 'recipe': dict({ - 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'image': 'rrNL', - 'name': 'Mousse de saumon', - 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', - 'perform_time': '2 Minutes', - 'prep_time': '15 Minutes', - 'rating': None, - 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', - 'recipe_yield': '12 servings', - 'slug': 'mousse-de-saumon', - 'total_time': '17 Minutes', - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), - 'title': None, - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), dict({ 'description': 'Dineren met de boys', 'entry_type': 'dinner', @@ -313,6 +257,37 @@ 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', }), ]), + 'drink': list([ + dict({ + 'description': None, + 'entry_type': 'drink', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': dict({ + '__type': "", + 'isoformat': '2024-01-22', + }), + 'mealplan_id': 211, + 'recipe': dict({ + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'nOPT', + 'name': 'Einfacher Nudelauflauf mit Brokkoli', + 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', + 'rating': None, + 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_yield': '4 servings', + 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), 'lunch': list([ dict({ 'description': None, @@ -433,6 +408,37 @@ 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), ]), + 'snack': list([ + dict({ + 'description': None, + 'entry_type': 'snack', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': dict({ + '__type': "", + 'isoformat': '2024-01-22', + }), + 'mealplan_id': 195, + 'recipe': dict({ + 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'rrNL', + 'name': 'Mousse de saumon', + 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', + 'perform_time': '2 Minutes', + 'prep_time': '15 Minutes', + 'rating': None, + 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', + 'recipe_yield': '12 servings', + 'slug': 'mousse-de-saumon', + 'total_time': '17 Minutes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), }), 'shoppinglist': dict({ '27edbaab-2ec6-441f-8490-0283ea77585f': dict({ diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index 18824686aba..ce8035f289b 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -26,7 +26,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'sw_version': 'v2.0.0', + 'sw_version': 'v3.7.0', 'via_device_id': None, }) # --- diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 41d03587a5b..30f70bc9273 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1967,7 +1967,7 @@ }), dict({ 'description': None, - 'entry_type': , + 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': HAFakeDate(2024, 1, 23), @@ -2123,7 +2123,7 @@ }), dict({ 'description': None, - 'entry_type': , + 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': HAFakeDate(2024, 1, 22), @@ -2175,7 +2175,7 @@ }), dict({ 'description': None, - 'entry_type': , + 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': HAFakeDate(2024, 1, 22), diff --git a/tests/components/mealie/test_calendar.py b/tests/components/mealie/test_calendar.py index cca4fcca673..ece87460965 100644 --- a/tests/components/mealie/test_calendar.py +++ b/tests/components/mealie/test_calendar.py @@ -4,7 +4,7 @@ from datetime import date from http import HTTPStatus from unittest.mock import AsyncMock, patch -from aiomealie import MealplanResponse +from aiomealie import About, MealplanResponse from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, Platform @@ -85,3 +85,25 @@ async def test_api_events( assert response.status == HTTPStatus.OK events = await response.json() assert events == snapshot + + +async def test_legacy_calendars( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that only legacy calendars are created for Mealie versions prior to 3.7.0.""" + + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + with patch("homeassistant.components.mealie.PLATFORMS", [Platform.CALENDAR]): + await setup_integration(hass, mock_config_entry) + + assert entity_registry.async_get("calendar.mealie_dessert") is None + assert entity_registry.async_get("calendar.mealie_drink") is None + assert entity_registry.async_get("calendar.mealie_snack") is None + assert entity_registry.async_get("calendar.mealie_breakfast") is not None + assert entity_registry.async_get("calendar.mealie_lunch") is not None + assert entity_registry.async_get("calendar.mealie_dinner") is not None + assert entity_registry.async_get("calendar.mealie_side") is not None diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 8c5d073e3e9..b69d37233c1 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -4,6 +4,7 @@ from datetime import date from unittest.mock import AsyncMock from aiomealie import ( + About, MealieConnectionError, MealieNotFoundError, MealieValidationError, @@ -272,6 +273,31 @@ async def test_service_set_random_mealplan( ) +async def test_service_set_random_mealplan_invalid_entry_type( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the set_random_mealplan service with invalid entry types for version.""" + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_RANDOM_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "dessert", + }, + blocking=True, + return_response=True, + ) + mock_mealie_client.random_mealplan.assert_not_called() + + @pytest.mark.parametrize( ("payload", "kwargs"), [ @@ -343,6 +369,32 @@ async def test_service_set_mealplan( ) +async def test_service_set_mealplan_invalid_entry_type( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the set_mealplan service with invalid entry types for version.""" + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "dessert", + ATTR_NOTE_TITLE: "Note Title", + }, + blocking=True, + return_response=True, + ) + mock_mealie_client.set_mealplan.assert_not_called() + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ diff --git a/tests/components/meteo_france/snapshots/test_sensor.ambr b/tests/components/meteo_france/snapshots/test_sensor.ambr index 2d048112bbb..e8f5ce87553 100644 --- a/tests/components/meteo_france/snapshots/test_sensor.ambr +++ b/tests/components/meteo_france/snapshots/test_sensor.ambr @@ -391,7 +391,7 @@ 'suggested_display_precision': 2, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'La Clusaz Pressure', 'platform': 'meteo_france', @@ -407,7 +407,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Météo-France', - 'device_class': 'pressure', + 'device_class': 'atmospheric_pressure', 'friendly_name': 'La Clusaz Pressure', 'state_class': , 'unit_of_measurement': , diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 4d8e94d3f82..94dfa420ee4 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -17,6 +17,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -73,6 +74,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -367,6 +369,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -423,6 +426,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -458,6 +462,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -514,6 +519,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1431,6 +1437,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1487,6 +1494,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1522,6 +1530,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1578,6 +1587,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1613,6 +1623,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1669,6 +1680,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1872,6 +1884,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1928,6 +1941,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2453,6 +2467,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2509,6 +2524,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2600,6 +2616,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2656,6 +2673,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2691,6 +2709,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2747,6 +2766,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3706,6 +3726,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3762,6 +3783,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3853,6 +3875,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3909,6 +3932,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -4823,6 +4847,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -4879,6 +4904,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -4970,6 +4996,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -5026,6 +5053,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -5061,6 +5089,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -5117,6 +5146,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6076,6 +6106,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6132,6 +6163,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6223,6 +6255,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6279,6 +6312,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -7193,6 +7227,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -7249,6 +7284,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py index aca6e37ff92..cd16a59a71d 100644 --- a/tests/components/mold_indicator/test_config_flow.py +++ b/tests/components/mold_indicator/test_config_flow.py @@ -229,7 +229,7 @@ async def test_config_flow_preview_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] is None assert result["preview"] == "mold_indicator" @@ -294,7 +294,7 @@ async def test_options_flow_preview( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "mold_indicator" @@ -361,7 +361,7 @@ async def test_options_flow_sensor_preview_config_entry_removed( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "mold_indicator" diff --git a/tests/components/mopeka/test_config_flow.py b/tests/components/mopeka/test_config_flow.py index d2887451629..d42e4d76856 100644 --- a/tests/components/mopeka/test_config_flow.py +++ b/tests/components/mopeka/test_config_flow.py @@ -254,7 +254,7 @@ async def test_async_step_reconfigure_options(hass: HomeAssistant) -> None: assert entry.data[CONF_MEDIUM_TYPE] == MediumType.AIR.value result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema: vol.Schema = result["data_schema"] medium_type_key = next( @@ -266,7 +266,7 @@ async def test_async_step_reconfigure_options(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_MEDIUM_TYPE: MediumType.FRESH_WATER.value}, ) - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY # Verify the new configuration assert entry.data[CONF_MEDIUM_TYPE] == MediumType.FRESH_WATER.value diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py index 2126aa7cdff..2c7ad8a0a74 100644 --- a/tests/components/music_assistant/test_init.py +++ b/tests/components/music_assistant/test_init.py @@ -164,7 +164,6 @@ async def test_authentication_required_triggers_reauth( music_assistant_client: MagicMock, ) -> None: """Test that AuthenticationRequired exception triggers reauth flow.""" - # Create a config entry config_entry = MockConfigEntry( domain=DOMAIN, title="Music Assistant", @@ -173,19 +172,44 @@ async def test_authentication_required_triggers_reauth( ) config_entry.add_to_hass(hass) - # Mock the client to raise AuthenticationRequired during connect music_assistant_client.connect.side_effect = AuthenticationRequired( "Authentication required" ) - # Try to set up the integration await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Verify the entry is in SETUP_ERROR state (auth failed) assert config_entry.state is ConfigEntryState.SETUP_ERROR - # Verify a reauth repair issue was created issue_reg = ir.async_get(hass) issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" assert issue_reg.async_get_issue("homeassistant", issue_id) + + +async def test_authentication_required_addon_no_reauth( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that AuthenticationRequired exception does not trigger reauth for addon.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={"url": "http://localhost:8095", "token": "old_token"}, + unique_id="test_server_id", + ) + config_entry.add_to_hass(hass) + + music_assistant_client.server_info.homeassistant_addon = True + + music_assistant_client.connect.side_effect = AuthenticationRequired( + "Authentication required" + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + issue_reg = ir.async_get(hass) + issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" + assert issue_reg.async_get_issue("homeassistant", issue_id) is None diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 108f2d7e592..8d77b79e833 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -29,7 +29,7 @@ async def test_load_unload( """Test loading and unloading the MySensors config entry.""" config_entry = integration - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_id = "binary_sensor.door_sensor_1_1" state = hass.states.get(entity_id) diff --git a/tests/components/nasweb/test_config_flow.py b/tests/components/nasweb/test_config_flow.py index a5f2dca680d..1392934da42 100644 --- a/tests/components/nasweb/test_config_flow.py +++ b/tests/components/nasweb/test_config_flow.py @@ -34,7 +34,7 @@ async def _add_test_config_entry(hass: HomeAssistant) -> ConfigFlowResult: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result.get("type") == FlowResultType.FORM + assert result.get("type") is FlowResultType.FORM assert not result.get("errors") result2 = await hass.config_entries.flow.async_configure( @@ -52,7 +52,7 @@ async def test_form( """Test the form.""" result = await _add_test_config_entry(hass) - assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("title") == "1.1.1.1" assert result.get("data") == TEST_USER_INPUT @@ -76,7 +76,7 @@ async def test_form_cannot_connect( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "cannot_connect"} @@ -97,7 +97,7 @@ async def test_form_invalid_auth( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "invalid_auth"} @@ -116,7 +116,7 @@ async def test_form_missing_internal_url( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "missing_internal_url"} @@ -136,13 +136,13 @@ async def test_form_missing_nasweb_data( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "missing_nasweb_data"} with patch(BASE_CONFIG_FLOW + "WebioAPI.status_subscription", return_value=False): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "missing_nasweb_data"} @@ -162,7 +162,7 @@ async def test_missing_status( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "missing_status"} @@ -182,7 +182,7 @@ async def test_form_exception( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_INPUT ) - assert result2.get("type") == FlowResultType.FORM + assert result2.get("type") is FlowResultType.FORM assert result2.get("errors") == {"base": "unknown"} @@ -204,5 +204,5 @@ async def test_form_already_configured( ) await hass.async_block_till_done() - assert result2_2.get("type") == FlowResultType.ABORT + assert result2_2.get("type") is FlowResultType.ABORT assert result2_2.get("reason") == "already_configured" diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_diagnostics.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e12044c81ce --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_diagnostics.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'coordinator_info': dict({ + 'departure': 'Ams', + 'departure_time': None, + 'destination': 'Rot', + 'name': 'To work', + 'via': 'Ht', + }), + 'device_info': dict({ + 'device_name': 'To work', + 'manufacturer': 'Nederlandse Spoorwegen', + 'model': 'Route', + 'subentry_id': '01K721DZPMEN39R5DK0ATBMSY8', + }), + 'first_trip': dict({ + 'arrival_platform_actual': '12', + 'arrival_platform_planned': '12', + 'arrival_time_actual': '2025-09-15 18:37:00+02:00', + 'arrival_time_planned': '2025-09-15 18:37:00+02:00', + 'departure_platform_actual': '4', + 'departure_platform_planned': '4', + 'departure_time_actual': '2025-09-15 16:35:00+02:00', + 'departure_time_planned': '2025-09-15 16:34:00+02:00', + 'going': True, + 'nr_transfers': 2, + 'status': 'NORMAL', + }), + 'trip_details': dict({ + 'has_first_trip': True, + 'has_next_trip': True, + 'trips_count': 8, + }), + }) +# --- +# name: test_entry_diagnostics + dict({ + 'coordinators': dict({ + '01K721DZPMEN39R5DK0ATBMSY8': dict({ + 'coordinator_info': dict({ + 'departure': 'Ams', + 'departure_time': None, + 'destination': 'Rot', + 'name': 'To work', + 'via': 'Ht', + }), + 'route_data': dict({ + 'has_first_trip': True, + 'has_next_trip': True, + 'trips_count': 8, + }), + }), + '01K721DZPMEN39R5DK0ATBMSY9': dict({ + 'coordinator_info': dict({ + 'departure': 'Hag', + 'departure_time': '08:00', + 'destination': 'Utr', + 'name': 'To home', + 'via': None, + }), + 'route_data': dict({ + 'has_first_trip': True, + 'has_next_trip': True, + 'trips_count': 8, + }), + }), + }), + 'entry_data': dict({ + 'api_key': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/nederlandse_spoorwegen/test_diagnostics.py b/tests/components/nederlandse_spoorwegen/test_diagnostics.py new file mode 100644 index 00000000000..4021c8ea620 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_diagnostics.py @@ -0,0 +1,69 @@ +"""Tests for the diagnostics data provided by the Nederlandse Spoorwegen integration.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.nederlandse_spoorwegen.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import SUBENTRY_ID_1 + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_nsapi: AsyncMock, +) -> None: + """Test config entry diagnostics.""" + mock_config_entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) + + # Trigger update for all coordinators before diagnostics + for coordinator in mock_config_entry.runtime_data.values(): + await coordinator.async_refresh() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot(exclude=props("created_at", "modified_at")) + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_nsapi: AsyncMock, +) -> None: + """Test device diagnostics.""" + # Ensure integration is set up so device exists + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, SUBENTRY_ID_1)}) + assert device is not None + + # Trigger update for the coordinator before diagnostics + coordinator = mock_config_entry.runtime_data[SUBENTRY_ID_1] + await coordinator.async_refresh() + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + assert result == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index b4b94efce5b..deb463c905d 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -313,7 +313,7 @@ async def setup_base_platform( await hass.async_block_till_done() yield _setup_func - if config_entry.state == ConfigEntryState.LOADED: + if config_entry.state is ConfigEntryState.LOADED: await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index d4ad81bd4e8..a7090c8e0e2 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -519,8 +519,8 @@ async def test_structure_update_event( assert not events assert entity_registry.async_get("camera.front") - # Currently need a manual reload to detect the new entity - assert not entity_registry.async_get("camera.back") + # New entity is now registered automatically when the event arrives + assert entity_registry.async_get("camera.back") @pytest.mark.parametrize( diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index b1839a4ae58..6effa34fa52 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -28,12 +28,16 @@ from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util.dt import utcnow from .common import ( PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_NEW_SUBSCRIPTION, + CreateDevice, PlatformSetup, + create_nest_event, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -348,3 +352,97 @@ async def test_migrate_unique_id( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == PROJECT_ID + + +async def test_add_devices( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, + subscriber: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that adding devices after initial setup works.""" + device_id1 = "enterprises/project-id/devices/device-id" + traits = { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + } + create_device.create(raw_traits=traits, raw_data={"name": device_id1}) + await setup_platform() + + device_entries = dr.async_entries_for_config_entry( + device_registry, hass.config_entries.async_entries(DOMAIN)[0].entry_id + ) + assert len(device_entries) == 1 + + # Add a second device and trigger a notification to refresh + device_id2 = "enterprises/project-id/devices/device-id-2" + create_device.create(raw_traits=traits, raw_data={"name": device_id2}) + + event_message = create_nest_event( + { + "eventId": "some-event-id", + "timestamp": utcnow().isoformat(timespec="seconds"), + "relationUpdate": { + "type": "UPDATED", + "subject": "some-subject", + "object": "some-object", + }, + }, + ) + await subscriber.async_receive_event(event_message) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, hass.config_entries.async_entries(DOMAIN)[0].entry_id + ) + assert len(device_entries) == 2 + + +async def test_stale_device_cleanup( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, + subscriber: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that stale devices are removed.""" + # Device #1 will be returned by the API. + device_id1 = "enterprises/project-id/devices/device-id" + device_registry.async_get_or_create( + config_entry_id=hass.config_entries.async_entries(DOMAIN)[0].entry_id, + identifiers={(DOMAIN, device_id1)}, + manufacturer="Google Nest", + ) + create_device.create( + raw_traits={ + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + }, + raw_data={"name": device_id1}, + ) + + # Device #2 is stale and should be removed. + device_registry.async_get_or_create( + config_entry_id=hass.config_entries.async_entries(DOMAIN)[0].entry_id, + identifiers={(DOMAIN, "enterprises/project-id/devices/device-id-stale")}, + manufacturer="Google Nest", + ) + + # Verify both devices are registered before setup. + device_entries = dr.async_entries_for_config_entry( + device_registry, hass.config_entries.async_entries(DOMAIN)[0].entry_id + ) + assert len(device_entries) == 2 + + # Setup should remove the stale device. + await setup_platform() + + device_entries = dr.async_entries_for_config_entry( + device_registry, hass.config_entries.async_entries(DOMAIN)[0].entry_id + ) + assert len(device_entries) == 1 + assert device_entries[0].identifiers == {(DOMAIN, device_id1)} diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 3411b3acd54..2f6c958e082 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -89,7 +89,7 @@ async def test_setup_component_with_webhook( assert hass.states.get(camera_entity_indoor).state == "streaming" - # Test outdoor camera events - not yet supported + # Test outdoor camera events assert hass.states.get(camera_entity_outdoor).state == "streaming" response = { "event_type": "off", @@ -100,8 +100,7 @@ async def test_setup_component_with_webhook( } await simulate_webhook(hass, webhook_id, response) - # The NOCamera-off push_type is not yet supported (assert should be "idle" when supported) - assert hass.states.get(camera_entity_outdoor).state == "streaming" + assert hass.states.get(camera_entity_outdoor).state == "idle" response = { "event_type": "on", @@ -425,8 +424,19 @@ async def test_service_set_camera_light_invalid_type( assert "NACamera does not have a floodlight" in excinfo.value.args[0] +@pytest.mark.parametrize( + ("camera_type", "camera_id", "camera_entity"), + [ + ("NACamera", "12:34:56:00:f1:62", "camera.hall"), + ("NOCamera", "12:34:56:10:b9:0e", "camera.front"), + ], +) async def test_camera_reconnect_webhook( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, + config_entry: MockConfigEntry, + camera_type: str, + camera_id: str, + camera_entity: str, ) -> None: """Test webhook event on camera reconnect.""" fake_post_hits = 0 @@ -472,7 +482,7 @@ async def test_camera_reconnect_webhook( # Fake camera reconnect response = { - "push_type": "NACamera-connection", + "push_type": f"{camera_type}-connection", } await simulate_webhook(hass, webhook_id, response) await hass.async_block_till_done() @@ -484,6 +494,30 @@ async def test_camera_reconnect_webhook( await hass.async_block_till_done() assert fake_post_hits >= calls + # Real camera disconnect + assert hass.states.get(camera_entity).state == "streaming" + response = { + "event_type": "disconnection", + "device_id": camera_id, + "camera_id": camera_id, + "event_id": "601dce1560abca1ebad9b723", + "push_type": f"{camera_type}-disconnection", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(camera_entity).state == "idle" + + response = { + "event_type": "connection", + "device_id": camera_id, + "camera_id": camera_id, + "event_id": "646227f1dc0dfa000ec5f350", + "push_type": f"{camera_type}-connection", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(camera_entity).state == "streaming" + async def test_webhook_person_event( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock diff --git a/tests/components/nintendo_parental_controls/conftest.py b/tests/components/nintendo_parental_controls/conftest.py index 6f4eded1e66..bd018a17cfd 100644 --- a/tests/components/nintendo_parental_controls/conftest.py +++ b/tests/components/nintendo_parental_controls/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pynintendoparental import NintendoParental from pynintendoparental.device import Device +from pynintendoparental.enum import DeviceTimerMode import pytest from homeassistant.components.nintendo_parental_controls.const import DOMAIN @@ -39,9 +40,11 @@ def mock_nintendo_device() -> Device: mock.today_playing_time = 110 mock.today_time_remaining = 10 mock.bedtime_alarm = time(hour=19) + mock.timer_mode = DeviceTimerMode.DAILY mock.add_extra_time.return_value = None mock.set_bedtime_alarm.return_value = None mock.update_max_daily_playtime.return_value = None + mock.set_timer_mode.return_value = None mock.forced_termination_mode = True mock.model = "Test Model" mock.generation = "P00" diff --git a/tests/components/nintendo_parental_controls/snapshots/test_select.ambr b/tests/components/nintendo_parental_controls/snapshots/test_select.ambr new file mode 100644 index 00000000000..e85f89a7295 --- /dev/null +++ b/tests/components/nintendo_parental_controls/snapshots/test_select.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_select[select.home_assistant_test_restriction_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'daily', + 'each_day_of_the_week', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.home_assistant_test_restriction_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restriction mode', + 'platform': 'nintendo_parental_controls', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'testdevid_timer_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.home_assistant_test_restriction_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Assistant Test Restriction mode', + 'options': list([ + 'daily', + 'each_day_of_the_week', + ]), + }), + 'context': , + 'entity_id': 'select.home_assistant_test_restriction_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'daily', + }) +# --- diff --git a/tests/components/nintendo_parental_controls/test_coordinator.py b/tests/components/nintendo_parental_controls/test_coordinator.py index 7472f661254..3d5110264c4 100644 --- a/tests/components/nintendo_parental_controls/test_coordinator.py +++ b/tests/components/nintendo_parental_controls/test_coordinator.py @@ -2,8 +2,13 @@ from unittest.mock import AsyncMock -from pynintendoauth.exceptions import InvalidOAuthConfigurationException +from pynintendoauth.exceptions import ( + HttpException, + InvalidOAuthConfigurationException, + InvalidSessionTokenException, +) from pynintendoparental.exceptions import NoDevicesFoundException +import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -14,16 +19,62 @@ from . import setup_integration from tests.common import MockConfigEntry -async def test_invalid_authentication( +@pytest.mark.parametrize( + ("exception", "translation_key", "expected_state", "expected_log_message"), + [ + ( + InvalidOAuthConfigurationException( + status_code=401, message="Authentication failed" + ), + "invalid_auth", + ConfigEntryState.SETUP_ERROR, + None, + ), + ( + NoDevicesFoundException(), + "no_devices_found", + ConfigEntryState.SETUP_ERROR, + None, + ), + ( + HttpException( + status_code=400, error_code="update_required", message="Update required" + ), + "update_required", + ConfigEntryState.SETUP_ERROR, + None, + ), + ( + HttpException( + status_code=500, error_code="unknown", message="Unknown error" + ), + None, + ConfigEntryState.SETUP_RETRY, + None, + ), + ( + InvalidSessionTokenException( + status_code=403, error_code="invalid_token", message="Invalid token" + ), + None, + ConfigEntryState.SETUP_RETRY, + "Session token invalid, will renew on next update", + ), + ], +) +async def test_update_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nintendo_client: AsyncMock, entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + exception: Exception, + translation_key: str, + expected_state: ConfigEntryState, + expected_log_message: str | None, ) -> None: - """Test handling of invalid authentication.""" - mock_nintendo_client.update.side_effect = InvalidOAuthConfigurationException( - status_code=401, message="Authentication failed" - ) + """Test handling of update errors.""" + mock_nintendo_client.update.side_effect = exception await setup_integration(hass, mock_config_entry) @@ -32,25 +83,13 @@ async def test_invalid_authentication( entity_registry, mock_config_entry.entry_id ) assert len(entries) == 0 - # Ensure the config entry is marked as error - assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + # Ensure the config entry is marked as expected state + assert mock_config_entry.state is expected_state -async def test_no_devices( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_nintendo_client: AsyncMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test handling of invalid authentication.""" - mock_nintendo_client.update.side_effect = NoDevicesFoundException() + # Ensure the correct translation key is used in the error + assert mock_config_entry.error_reason_translation_key == translation_key - await setup_integration(hass, mock_config_entry) - - # Ensure no entities are created - entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - assert len(entries) == 0 - # Ensure the config entry is marked as error - assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + # If there's an expected log message, check that it was logged + if expected_log_message: + assert expected_log_message in caplog.text diff --git a/tests/components/nintendo_parental_controls/test_init.py b/tests/components/nintendo_parental_controls/test_init.py index b149bae0b85..a6383edb12d 100644 --- a/tests/components/nintendo_parental_controls/test_init.py +++ b/tests/components/nintendo_parental_controls/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +from pynintendoauth.exceptions import InvalidOAuthConfigurationException + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -27,4 +29,29 @@ async def test_invalid_authentication( ) assert len(entries) == 0 # Ensure the config entry is marked as error - assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_authenticator: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the reauth flow is triggered.""" + mock_nintendo_authenticator.async_complete_login.side_effect = ( + InvalidOAuthConfigurationException( + status_code=401, message="Authentication failed" + ) + ) + await setup_integration(hass, mock_config_entry) + + # Ensure no entities are created + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entries) == 0 + # Ensure the config entry is marked as needing reauth + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + assert mock_config_entry.error_reason_translation_key == "auth_expired" diff --git a/tests/components/nintendo_parental_controls/test_select.py b/tests/components/nintendo_parental_controls/test_select.py new file mode 100644 index 00000000000..589d434282d --- /dev/null +++ b/tests/components/nintendo_parental_controls/test_select.py @@ -0,0 +1,64 @@ +"""Tests for Nintendo Switch Parental Controls select platform.""" + +from unittest.mock import AsyncMock, patch + +from pynintendoparental.enum import DeviceTimerMode +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_select( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test select platform.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.SELECT], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, +) -> None: + """Test select option service.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.SELECT], + ): + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.home_assistant_test_restriction_mode", + ATTR_OPTION: DeviceTimerMode.EACH_DAY_OF_THE_WEEK.name.lower(), + }, + blocking=True, + ) + mock_nintendo_device.set_timer_mode.assert_awaited_once_with( + DeviceTimerMode.EACH_DAY_OF_THE_WEEK + ) diff --git a/tests/components/nintendo_parental_controls/test_services.py b/tests/components/nintendo_parental_controls/test_services.py index 970adeaa4ad..45fbdcdd46e 100644 --- a/tests/components/nintendo_parental_controls/test_services.py +++ b/tests/components/nintendo_parental_controls/test_services.py @@ -13,7 +13,7 @@ from homeassistant.components.nintendo_parental_controls.services import ( ) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -51,7 +51,7 @@ async def test_add_bonus_time_invalid_device( ) -> None: """Test add bonus time service.""" await setup_integration(hass, mock_config_entry) - with pytest.raises(HomeAssistantError) as err: + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( DOMAIN, NintendoParentalServices.ADD_BONUS_TIME, @@ -63,3 +63,30 @@ async def test_add_bonus_time_invalid_device( ) assert err.value.translation_domain == DOMAIN assert err.value.translation_key == "device_not_found" + + +async def test_add_bonus_time_device_id_not_nintendo( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, +) -> None: + """Test add bonus time service with a device that is not a valid Nintendo device.""" + await setup_integration(hass, mock_config_entry) + # Create a device that does not have a Nintendo identifier + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55")}, + ) + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + NintendoParentalServices.ADD_BONUS_TIME, + { + ATTR_DEVICE_ID: device_entry.id, + ATTR_BONUS_TIME: 15, + }, + blocking=True, + ) + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "invalid_device" diff --git a/tests/components/nmap_tracker/test_init.py b/tests/components/nmap_tracker/test_init.py index 14233892176..6fd1ca5122c 100644 --- a/tests/components/nmap_tracker/test_init.py +++ b/tests/components/nmap_tracker/test_init.py @@ -58,7 +58,7 @@ async def test_migrate_entry(hass: HomeAssistant) -> None: CONF_MAC_EXCLUDE: [], CONF_OPTIONS: DEFAULT_OPTIONS, } - assert updated_entry.state == ConfigEntryState.LOADED + assert updated_entry.state is ConfigEntryState.LOADED async def test_migrate_entry_fails_on_downgrade(hass: HomeAssistant) -> None: @@ -90,4 +90,4 @@ async def test_migrate_entry_fails_on_downgrade(hass: HomeAssistant) -> None: updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) assert updated_entry assert updated_entry.version == 2 - assert updated_entry.state == ConfigEntryState.MIGRATION_ERROR + assert updated_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 00118d28336..22b09c081fe 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -343,7 +343,7 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_topic"} result = await hass.config_entries.subentries.async_configure( diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index bd4bb705889..2b145c03ee2 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -57,7 +57,7 @@ async def test_sensors( state = hass.states.get("sensor.octoprint_current_state") assert state is not None - assert state.state == "Operational" + assert state.state == "operational" assert state.name == "OctoPrint Current State" entry = entity_registry.async_get("sensor.octoprint_current_state") assert entry.unique_id == "Current State-uuid" diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index f074aa8e6bd..7e7758c58cd 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -52,7 +52,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -90,6 +92,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Ohme Home Pro Current', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -164,7 +167,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -205,6 +210,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Ohme Home Pro Power', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -286,7 +292,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -324,6 +332,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Ohme Home Pro Vehicle battery', + 'state_class': , 'unit_of_measurement': '%', }), 'context': , diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index de895afc96a..202514a77b5 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -207,7 +207,7 @@ async def test_subentry_unsupported_model( subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( hass, subentry.subentry_id ) - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM assert subentry_flow["step_id"] == "init" # Configure initial step @@ -220,7 +220,7 @@ async def test_subentry_unsupported_model( }, ) await hass.async_block_till_done() - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM assert subentry_flow["step_id"] == "advanced" # Configure advanced step @@ -241,6 +241,8 @@ async def test_subentry_unsupported_model( ("o4-mini", ["low", "medium", "high"]), ("gpt-5", ["minimal", "low", "medium", "high"]), ("gpt-5.1", ["none", "low", "medium", "high"]), + ("gpt-5.2", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.2-pro", ["medium", "high", "xhigh"]), ], ) async def test_subentry_reasoning_effort_list( @@ -798,7 +800,7 @@ async def test_subentry_switching( assert subentry_flow["step_id"] == "init" for step_options in new_options: - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM # Test that current options are showed as suggested values: for key in subentry_flow["data_schema"].schema: @@ -832,7 +834,7 @@ async def test_subentry_web_search_user_location( subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( hass, subentry.subentry_id ) - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM assert subentry_flow["step_id"] == "init" # Configure initial step @@ -843,7 +845,7 @@ async def test_subentry_web_search_user_location( CONF_PROMPT: "Speak like a pirate", }, ) - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM assert subentry_flow["step_id"] == "advanced" # Configure advanced step @@ -857,7 +859,7 @@ async def test_subentry_web_search_user_location( }, ) await hass.async_block_till_done() - assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["type"] is FlowResultType.FORM assert subentry_flow["step_id"] == "model" hass.config.country = "US" diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index 9ae6be407ec..d23ae55e77e 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest -from python_overseerr import MovieDetails, RequestCount, RequestResponse +from python_overseerr import IssueCount, MovieDetails, RequestCount, RequestResponse from python_overseerr.models import TVDetails, WebhookNotificationConfig from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL @@ -49,6 +49,9 @@ def mock_overseerr_client() -> Generator[AsyncMock]: client.get_request_count.return_value = RequestCount.from_json( load_fixture("request_count.json", DOMAIN) ) + client.get_issue_count.return_value = IssueCount.from_json( + load_fixture("issue_count.json", DOMAIN) + ) client.get_webhook_notification_config.return_value = ( WebhookNotificationConfig.from_json( load_fixture("webhook_config.json", DOMAIN) diff --git a/tests/components/overseerr/fixtures/issue_count.json b/tests/components/overseerr/fixtures/issue_count.json new file mode 100644 index 00000000000..8cfb6eabda3 --- /dev/null +++ b/tests/components/overseerr/fixtures/issue_count.json @@ -0,0 +1,9 @@ +{ + "total": 15, + "video": 6, + "audio": 4, + "subtitles": 3, + "others": 2, + "open": 10, + "closed": 5 +} diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index 2b3388444d2..48619a41ba7 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -1,6 +1,6 @@ { "enabled": true, - "types": 222, + "types": 4062, "options": { "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_id\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" diff --git a/tests/components/overseerr/fixtures/webhook_issue_reported.json b/tests/components/overseerr/fixtures/webhook_issue_reported.json new file mode 100644 index 00000000000..425e2752942 --- /dev/null +++ b/tests/components/overseerr/fixtures/webhook_issue_reported.json @@ -0,0 +1,23 @@ +{ + "notification_type": "ISSUE_REPORTED", + "subject": "New Issue Reported", + "message": "A new video issue has been reported for Interstellar", + "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg", + "media": { + "media_type": "movie", + "tmdb_id": "157336", + "tvdb_id": "", + "status": "available", + "status4k": "unknown" + }, + "issue": { + "issue_id": "1", + "issue_type": "video", + "issue_status": "open", + "reported_by_email": "user@example.com", + "reported_by_username": "testuser", + "reported_by_avatar": "/os_logo_square.png", + "reported_by_settings_discord_id": "", + "reported_by_settings_telegram_chat_id": "" + } +} diff --git a/tests/components/overseerr/snapshots/test_diagnostics.ambr b/tests/components/overseerr/snapshots/test_diagnostics.ambr index 164257bb9f1..4ed2abb89d5 100644 --- a/tests/components/overseerr/snapshots/test_diagnostics.ambr +++ b/tests/components/overseerr/snapshots/test_diagnostics.ambr @@ -2,14 +2,25 @@ # name: test_diagnostics_polling_instance dict({ 'coordinator_data': dict({ - 'approved': 11, - 'available': 8, - 'declined': 0, - 'movie': 9, - 'pending': 0, - 'processing': 3, - 'total': 11, - 'tv': 2, + 'issues': dict({ + 'audio': 4, + 'closed': 5, + 'open': 10, + 'others': 2, + 'subtitles': 3, + 'total': 15, + 'video': 6, + }), + 'requests': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), }), 'has_cloudhooks': False, }) @@ -17,14 +28,25 @@ # name: test_diagnostics_webhook_instance dict({ 'coordinator_data': dict({ - 'approved': 11, - 'available': 8, - 'declined': 0, - 'movie': 9, - 'pending': 0, - 'processing': 3, - 'total': 11, - 'tv': 2, + 'issues': dict({ + 'audio': 4, + 'closed': 5, + 'open': 10, + 'others': 2, + 'subtitles': 3, + 'total': 15, + 'video': 6, + }), + 'requests': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), }), 'has_cloudhooks': True, }) diff --git a/tests/components/overseerr/snapshots/test_sensor.ambr b/tests/components/overseerr/snapshots/test_sensor.ambr index 44613d6117c..2ea19617e8e 100644 --- a/tests/components/overseerr/snapshots/test_sensor.ambr +++ b/tests/components/overseerr/snapshots/test_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_all_entities[sensor.overseerr_audio_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_audio_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Audio issues', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_issues', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-audio_issues', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.overseerr_audio_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Audio issues', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.overseerr_audio_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- # name: test_all_entities[sensor.overseerr_available_requests-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -51,6 +102,57 @@ 'state': '8', }) # --- +# name: test_all_entities[sensor.overseerr_closed_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_closed_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed issues', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_issues', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-closed_issues', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.overseerr_closed_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Closed issues', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.overseerr_closed_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_all_entities[sensor.overseerr_declined_requests-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -155,6 +257,57 @@ 'state': '9', }) # --- +# name: test_all_entities[sensor.overseerr_open_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_open_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open issues', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_issues', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-open_issues', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.overseerr_open_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Open issues', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.overseerr_open_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- # name: test_all_entities[sensor.overseerr_pending_requests-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -259,6 +412,108 @@ 'state': '3', }) # --- +# name: test_all_entities[sensor.overseerr_subtitle_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_subtitle_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Subtitle issues', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'subtitle_issues', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-subtitle_issues', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.overseerr_subtitle_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Subtitle issues', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.overseerr_subtitle_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[sensor.overseerr_total_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_total_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total issues', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_issues', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-total_issues', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.overseerr_total_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Total issues', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.overseerr_total_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- # name: test_all_entities[sensor.overseerr_total_requests-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -363,3 +618,54 @@ 'state': '2', }) # --- +# name: test_all_entities[sensor.overseerr_video_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_video_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video issues', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_issues', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-video_issues', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.overseerr_video_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Video issues', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.overseerr_video_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 66e6a5c134c..670622cfc8c 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -72,7 +72,7 @@ async def test_proper_webhook_configuration( """Test the webhook configuration.""" await setup_integration(hass, mock_config_entry) - assert REGISTERED_NOTIFICATIONS == 222 + assert REGISTERED_NOTIFICATIONS == 4062 mock_overseerr_client.test_webhook_notification_config.assert_not_called() mock_overseerr_client.set_webhook_notification_config.assert_not_called() @@ -83,7 +83,6 @@ async def test_proper_webhook_configuration( [ {"return_value.enabled": False}, {"return_value.types": 4}, - {"return_value.types": 4062}, { "return_value.options": WebhookNotificationOptions( webhook_url="http://example.com", json_payload=JSON_PAYLOAD @@ -99,7 +98,6 @@ async def test_proper_webhook_configuration( ids=[ "Disabled", "Smaller scope", - "Bigger scope", "Webhook URL", "JSON Payload", ], @@ -124,7 +122,6 @@ async def test_webhook_configuration_need_update( [ {"return_value.enabled": False}, {"return_value.types": 4}, - {"return_value.types": 4062}, { "return_value.options": WebhookNotificationOptions( webhook_url="http://example.com", json_payload=JSON_PAYLOAD @@ -140,7 +137,6 @@ async def test_webhook_configuration_need_update( ids=[ "Disabled", "Smaller scope", - "Bigger scope", "Webhook URL", "JSON Payload", ], diff --git a/tests/components/overseerr/test_sensor.py b/tests/components/overseerr/test_sensor.py index 7ce605e0413..19c10af30b9 100644 --- a/tests/components/overseerr/test_sensor.py +++ b/tests/components/overseerr/test_sensor.py @@ -39,7 +39,7 @@ async def test_webhook_trigger_update( mock_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: - """Test all entities.""" + """Test webhook triggers coordinator update for request sensors.""" await setup_integration(hass, mock_config_entry) assert hass.states.get("sensor.overseerr_available_requests").state == "8" @@ -57,3 +57,35 @@ async def test_webhook_trigger_update( await hass.async_block_till_done() assert hass.states.get("sensor.overseerr_available_requests").state == "7" + + +async def test_webhook_issue_trigger_update( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test webhook triggers coordinator update for issue sensors.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.overseerr_total_issues").state == "15" + assert hass.states.get("sensor.overseerr_open_issues").state == "10" + assert hass.states.get("sensor.overseerr_video_issues").state == "6" + + mock_overseerr_client.get_issue_count.return_value.total = 16 + mock_overseerr_client.get_issue_count.return_value.open = 11 + mock_overseerr_client.get_issue_count.return_value.video = 7 + client = await hass_client_no_auth() + + await call_webhook( + hass, + await async_load_json_object_fixture( + hass, "webhook_issue_reported.json", DOMAIN + ), + client, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.overseerr_total_issues").state == "16" + assert hass.states.get("sensor.overseerr_open_issues").state == "11" + assert hass.states.get("sensor.overseerr_video_issues").state == "7" diff --git a/tests/components/pglab/test_config_flow.py b/tests/components/pglab/test_config_flow.py index 81ed010920e..5c844fd63d2 100644 --- a/tests/components/pglab/test_config_flow.py +++ b/tests/components/pglab/test_config_flow.py @@ -49,7 +49,7 @@ async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> N result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == {"discovery_prefix": "pglab/discovery"} diff --git a/tests/components/playstation_network/test_image.py b/tests/components/playstation_network/test_image.py index 0dc52646d9e..b0d4d1ac79a 100644 --- a/tests/components/playstation_network/test_image.py +++ b/tests/components/playstation_network/test_image.py @@ -75,8 +75,7 @@ async def test_image_platform( freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert (state := hass.states.get("image.testuser_avatar")) assert state.state == "2025-06-16T00:00:30+00:00" diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index 8a6dceb1e47..58aaf5d96ab 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -130,6 +130,10 @@ async def test_lookup_media_for_other_integrations( PLEX_URI_SCHEME + '{"library_name": "Music", "artist_name": "Artist", "shuffle": 1}' ) + CONTENT_ID_CONTINUOUS = ( + PLEX_URI_SCHEME + + '{"library_name": "Music", "artist_name": "Artist", "continuous": 1}' + ) # Test with no Plex integration available with pytest.raises(HomeAssistantError) as excinfo: @@ -158,6 +162,7 @@ async def test_lookup_media_for_other_integrations( ) assert isinstance(result.media, plexapi.audio.Artist) assert not result.shuffle + assert not result.continuous # Test media key payload without playqueue result = process_plex_payload( @@ -180,6 +185,13 @@ async def test_lookup_media_for_other_integrations( assert isinstance(result.media, plexapi.audio.Artist) assert result.shuffle + # Test continuous without playqueue + result = process_plex_payload( + hass, MediaType.MUSIC, CONTENT_ID_CONTINUOUS, supports_playqueues=False + ) + assert isinstance(result.media, plexapi.audio.Artist) + assert result.continuous + # Test with media not found with patch( "plexapi.library.LibrarySection.search", @@ -208,6 +220,10 @@ async def test_lookup_media_for_other_integrations( result = process_plex_payload(hass, MediaType.MUSIC, CONTENT_ID_SHUFFLE) assert isinstance(result.media, plexapi.playqueue.PlayQueue) + # Test playqueue is created with continuous + result = process_plex_payload(hass, MediaType.MUSIC, CONTENT_ID_CONTINUOUS) + assert isinstance(result.media, plexapi.playqueue.PlayQueue) + async def test_lookup_media_with_urls(hass: HomeAssistant, mock_plex_server) -> None: """Test media lookup for media_player.play_media calls from cast/sonos.""" @@ -228,3 +244,12 @@ async def test_lookup_media_with_urls(hass: HomeAssistant, mock_plex_server) -> assert isinstance(result.media, plexapi.audio.Track) assert result.shuffle is True assert result.offset == 0 + + # Test URL format with continuous + CONTENT_ID_URL_WITH_CONTINUOUS = CONTENT_ID_URL + "?continuous=1" + result = process_plex_payload( + hass, MediaType.MUSIC, CONTENT_ID_URL_WITH_CONTINUOUS, supports_playqueues=False + ) + assert isinstance(result.media, plexapi.audio.Track) + assert result.continuous is True + assert result.offset == 0 diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/data.json b/tests/components/plugwise/fixtures/m_adam_cooling/data.json index fdd52b1efe3..9b95475ebb6 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/data.json @@ -91,6 +91,21 @@ }, "vendor": "Plugwise" }, + "c9293d1d68ee48fc8843c6f0dee2b6be": { + "dev_class": "pumping", + "members": [ + "854f8a9b0e7e425db97f1f110e1ce4b3", + "ad4838d7d35c4d6ea796ee12ae5aedf8" + ], + "model": "Group", + "name": "Vloerverwarming", + "sensors": { + "electricity_consumed": 45.0, + "electricity_produced": 0.0, + "temperature": 20.1 + }, + "vendor": "Plugwise" + }, "da224107914542988a88561b4452b0f6": { "binary_sensors": { "plugwise_notification": false @@ -173,8 +188,12 @@ "2568cc4b9c1e401495d4741a5f89bee1", "29542b2b6a6a4169acecc15c72a599b8" ], - "model": "Switchgroup", + "model": "Group", "name": "Test", + "sensors": { + "electricity_consumed": 16.5, + "electricity_produced": 0.0 + }, "switches": { "relay": true }, diff --git a/tests/components/plugwise/fixtures/m_adam_heating/data.json b/tests/components/plugwise/fixtures/m_adam_heating/data.json index 2f3951c0c6b..47a8fdeb2b6 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/data.json @@ -96,6 +96,21 @@ }, "vendor": "Plugwise" }, + "c9293d1d68ee48fc8843c6f0dee2b6be": { + "dev_class": "pumping", + "members": [ + "854f8a9b0e7e425db97f1f110e1ce4b3", + "ad4838d7d35c4d6ea796ee12ae5aedf8" + ], + "model": "Group", + "name": "Vloerverwarming", + "sensors": { + "electricity_consumed": 45.0, + "electricity_produced": 0.0, + "temperature": 20.1 + }, + "vendor": "Plugwise" + }, "da224107914542988a88561b4452b0f6": { "binary_sensors": { "plugwise_notification": false @@ -172,8 +187,12 @@ "2568cc4b9c1e401495d4741a5f89bee1", "29542b2b6a6a4169acecc15c72a599b8" ], - "model": "Switchgroup", + "model": "Group", "name": "Test", + "sensors": { + "electricity_consumed": 16.5, + "electricity_produced": 0.0 + }, "switches": { "relay": true }, diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json index f1880ba69e1..126031795a8 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -505,6 +505,20 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A06" }, + "e117db6848394c8cb70d9c28e63d92d2": { + "dev_class": "pumping", + "members": [ + "78d1126fc4c743db81b61c20e88342a7", + "b59bcebaf94b499ea7d46e4a66fb62d8" + ], + "model": "Group", + "name": "Vloerverwarming Woonkamer", + "sensors": { + "electricity_consumed": 35.6, + "temperature": 20.9 + }, + "vendor": "Plugwise" + }, "e7693eb9582644e5b865dba8d4447cf1": { "available": true, "binary_sensors": { @@ -539,8 +553,12 @@ "02cf28bfec924855854c544690a609ef", "4a810418d5394b3f82727340b91ba740" ], - "model": "Switchgroup", + "model": "Group", "name": "Test", + "sensors": { + "electricity_consumed": 14.8, + "electricity_produced": 0.0 + }, "switches": { "relay": true }, diff --git a/tests/components/plugwise/fixtures/stretch_v31/data.json b/tests/components/plugwise/fixtures/stretch_v31/data.json index 250839d08a8..9927b5c1391 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/data.json @@ -91,7 +91,7 @@ "059e4d03c7a34d278add5c7a4a781d19", "cfe95cf3de1948c0b8955125bf754614" ], - "model": "Switchgroup", + "model": "Group", "name": "Schakel", "switches": { "relay": true @@ -107,7 +107,7 @@ "cfe95cf3de1948c0b8955125bf754614", "e1c884e7dede431dadee09506ec4f859" ], - "model": "Switchgroup", + "model": "Group", "name": "Stroomvreters", "switches": { "relay": true diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 91411c323ac..9fe05968147 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -554,6 +554,20 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A06', }), + 'e117db6848394c8cb70d9c28e63d92d2': dict({ + 'dev_class': 'pumping', + 'members': list([ + '78d1126fc4c743db81b61c20e88342a7', + 'b59bcebaf94b499ea7d46e4a66fb62d8', + ]), + 'model': 'Group', + 'name': 'Vloerverwarming Woonkamer', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'temperature': 20.9, + }), + 'vendor': 'Plugwise', + }), 'e7693eb9582644e5b865dba8d4447cf1': dict({ 'available': True, 'binary_sensors': dict({ @@ -588,8 +602,12 @@ '02cf28bfec924855854c544690a609ef', '4a810418d5394b3f82727340b91ba740', ]), - 'model': 'Switchgroup', + 'model': 'Group', 'name': 'Test', + 'sensors': dict({ + 'electricity_consumed': 14.8, + 'electricity_produced': 0.0, + }), 'switches': dict({ 'relay': True, }), diff --git a/tests/components/plugwise/snapshots/test_select.ambr b/tests/components/plugwise/snapshots/test_select.ambr index c2680f7bcea..90ace520e2d 100644 --- a/tests/components/plugwise/snapshots/test_select.ambr +++ b/tests/components/plugwise/snapshots/test_select.ambr @@ -141,7 +141,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.bathroom_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -263,7 +263,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.living_room_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -386,7 +386,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.badkamer_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -451,7 +451,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.bios_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -516,7 +516,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.jessie_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -581,7 +581,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.woonkamer_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/plugwise/snapshots/test_sensor.ambr b/tests/components/plugwise/snapshots/test_sensor.ambr index 7c7715dfd28..113fba03b15 100644 --- a/tests/components/plugwise/snapshots/test_sensor.ambr +++ b/tests/components/plugwise/snapshots/test_sensor.ambr @@ -1216,6 +1216,118 @@ 'state': '37.0', }) # --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.test_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'e8ef2a01ed3b4139a53bf749204fe6b4-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.test_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.test_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'e8ef2a01ed3b4139a53bf749204fe6b4-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.test_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.tom_badkamer_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1489,6 +1601,174 @@ 'state': '100.0', }) # --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.vloerverwarming_electricity_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vloerverwarming_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'c9293d1d68ee48fc8843c6f0dee2b6be-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.vloerverwarming_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Vloerverwarming Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vloerverwarming_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.vloerverwarming_electricity_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vloerverwarming_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'c9293d1d68ee48fc8843c6f0dee2b6be-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.vloerverwarming_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Vloerverwarming Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vloerverwarming_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.vloerverwarming_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vloerverwarming_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c9293d1d68ee48fc8843c6f0dee2b6be-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0-False-m_adam_heating][sensor.vloerverwarming_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vloerverwarming Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vloerverwarming_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.1', + }) +# --- # name: test_anna_p1_sensor_snapshot[platforms0][sensor.anna_illuminance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 906703767e2..02f434b2366 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, + InvalidSetupError, InvalidXMLError, PlugwiseError, ResponseError, @@ -89,6 +90,7 @@ async def test_load_unload_config_entry( [ (ConnectionFailedError, ConfigEntryState.SETUP_RETRY), (InvalidAuthentication, ConfigEntryState.SETUP_ERROR), + (InvalidSetupError, ConfigEntryState.SETUP_ERROR), (InvalidXMLError, ConfigEntryState.SETUP_RETRY), (PlugwiseError, ConfigEntryState.SETUP_RETRY), (ResponseError, ConfigEntryState.SETUP_RETRY), @@ -169,7 +171,7 @@ async def test_migrate_unique_id_temperature( """Test migration of unique_id.""" mock_config_entry.add_to_hass(hass) - entity: entity_registry.RegistryEntry = entity_registry.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, ) @@ -257,7 +259,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 51 + == 56 ) assert ( len( @@ -265,7 +267,7 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 10 + == 11 ) # Add a 2nd Tom/Floor @@ -289,7 +291,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 58 + == 63 ) assert ( len( @@ -297,7 +299,7 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 11 + == 12 ) item_list: list[str] = [] for device_entry in list(device_registry.devices.values()): @@ -320,7 +322,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 51 + == 56 ) assert ( len( @@ -328,9 +330,37 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 10 + == 11 ) item_list: list[str] = [] for device_entry in list(device_registry.devices.values()): item_list.extend(x[1] for x in device_entry.identifiers) assert "1772a4ea304041adb83f357b751341ff" not in item_list + + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +async def test_delete_removed_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smile_adam_heat_cool: MagicMock, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test device removal at integration init.""" + data = mock_smile_adam_heat_cool.async_update.return_value + + item_list: list[str] = [] + for device_entry in device_registry.devices.values(): + item_list.extend(x[1] for x in device_entry.identifiers) + assert "14df5c4dc8cb4ba69f9d1ac0eaf7c5c6" in item_list + + data.pop("14df5c4dc8cb4ba69f9d1ac0eaf7c5c6") + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + item_list = [] + for device_entry in device_registry.devices.values(): + item_list.extend(x[1] for x in device_entry.identifiers) + assert "14df5c4dc8cb4ba69f9d1ac0eaf7c5c6" not in item_list diff --git a/tests/components/pooldose/conftest.py b/tests/components/pooldose/conftest.py index c593704a7ed..50995f7aa80 100644 --- a/tests/components/pooldose/conftest.py +++ b/tests/components/pooldose/conftest.py @@ -60,6 +60,7 @@ def mock_pooldose_client(device_info: dict[str, Any]) -> Generator[MagicMock]: ) client.set_switch = AsyncMock(return_value=RequestStatus.SUCCESS) + client.set_select = AsyncMock(return_value=RequestStatus.SUCCESS) client.is_connected = True yield client diff --git a/tests/components/pooldose/fixtures/instantvalues.json b/tests/components/pooldose/fixtures/instantvalues.json index b8213698d1f..e26a0068b62 100644 --- a/tests/components/pooldose/fixtures/instantvalues.json +++ b/tests/components/pooldose/fixtures/instantvalues.json @@ -18,7 +18,7 @@ }, "flow_rate": { "value": 150, - "unit": "l/s" + "unit": "L/s" }, "ph_type_dosing": { "value": "alcalyne", @@ -198,7 +198,28 @@ }, "select": { "water_meter_unit": { - "value": "m³" + "value": "m3" + }, + "flow_rate_unit": { + "value": "L/s" + }, + "ph_type_dosing_set": { + "value": "acid" + }, + "ph_type_dosing_method": { + "value": "proportional" + }, + "orp_type_dosing_set": { + "value": "low" + }, + "orp_type_dosing_method": { + "value": "on_off" + }, + "cl_type_dosing_set": { + "value": "high" + }, + "cl_type_dosing_method": { + "value": "timed" } } } diff --git a/tests/components/pooldose/snapshots/test_select.ambr b/tests/components/pooldose/snapshots/test_select.ambr new file mode 100644 index 00000000000..a33603463e8 --- /dev/null +++ b/tests/components/pooldose/snapshots/test_select.ambr @@ -0,0 +1,469 @@ +# serializer version: 1 +# name: test_all_selects[select.pool_device_chlorine_dosing_method-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_chlorine_dosing_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine dosing method', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl_type_dosing_method', + 'unique_id': 'TEST123456789_cl_type_dosing_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_chlorine_dosing_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine dosing method', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_chlorine_dosing_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'timed', + }) +# --- +# name: test_all_selects[select.pool_device_chlorine_dosing_set-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_chlorine_dosing_set', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine dosing set', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl_type_dosing_set', + 'unique_id': 'TEST123456789_cl_type_dosing_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_chlorine_dosing_set-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine dosing set', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_chlorine_dosing_set', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_all_selects[select.pool_device_flow_rate_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_flow_rate_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow rate unit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flow_rate_unit', + 'unique_id': 'TEST123456789_flow_rate_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_flow_rate_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Flow rate unit', + 'options': list([ + , + , + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_flow_rate_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'L/s', + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_method-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_orp_dosing_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ORP dosing method', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_type_dosing_method', + 'unique_id': 'TEST123456789_orp_type_dosing_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device ORP dosing method', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_orp_dosing_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_off', + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_set-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_orp_dosing_set', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ORP dosing set', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_type_dosing_set', + 'unique_id': 'TEST123456789_orp_type_dosing_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_set-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device ORP dosing set', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_orp_dosing_set', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_method-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_ph_dosing_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH dosing method', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_type_dosing_method', + 'unique_id': 'TEST123456789_ph_type_dosing_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device pH dosing method', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_ph_dosing_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'proportional', + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_set-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alcalyne', + 'acid', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_ph_dosing_set', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH dosing set', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_type_dosing_set', + 'unique_id': 'TEST123456789_ph_type_dosing_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_set-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device pH dosing set', + 'options': list([ + 'alcalyne', + 'acid', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_ph_dosing_set', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'acid', + }) +# --- +# name: test_all_selects[select.pool_device_water_meter_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_water_meter_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water meter unit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_meter_unit', + 'unique_id': 'TEST123456789_water_meter_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_water_meter_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Water meter unit', + 'options': list([ + , + , + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_water_meter_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'm³', + }) +# --- diff --git a/tests/components/pooldose/test_select.py b/tests/components/pooldose/test_select.py new file mode 100644 index 00000000000..4ca9e0c7640 --- /dev/null +++ b/tests/components/pooldose/test_select.py @@ -0,0 +1,242 @@ +"""Tests for the Seko PoolDose select platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pooldose.request_status import RequestStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + Platform, + UnitOfVolume, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SELECT] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_all_selects( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Pooldose select entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_entity_unavailable_no_coordinator_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test select entity becomes unavailable when coordinator has no data.""" + # Verify entity has a state initially + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == UnitOfVolume.CUBIC_METERS + + # Update coordinator data to None + mock_pooldose_client.instant_values_structured.return_value = (None, None) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check entity becomes unavailable + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == "unavailable" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_state_changes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test select state changes when coordinator updates.""" + # Initial state + ph_method_state = hass.states.get("select.pool_device_ph_dosing_method") + assert ph_method_state.state == "proportional" + + # Update coordinator data with select value changed + current_data = mock_pooldose_client.instant_values_structured.return_value[1] + updated_data = current_data.copy() + updated_data["select"]["ph_type_dosing_method"]["value"] = "timed" + + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + updated_data, + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check state changed + ph_method_state = hass.states.get("select.pool_device_ph_dosing_method") + assert ph_method_state.state == "timed" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_option_unit_conversion( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting an option with unit conversion (HA unit -> API value).""" + # Verify initial state is m³ (displayed as Unicode) + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == UnitOfVolume.CUBIC_METERS + + # Select Liters option + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_water_meter_unit", + ATTR_OPTION: UnitOfVolume.LITERS, + }, + blocking=True, + ) + + # Verify API was called with "L" (not Unicode) + mock_pooldose_client.set_select.assert_called_once_with("water_meter_unit", "L") + + # Verify state updated to L (Unicode) + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == UnitOfVolume.LITERS + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_option_flow_rate_unit_conversion( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting flow rate unit with conversion.""" + # Verify initial state + flow_rate_state = hass.states.get("select.pool_device_flow_rate_unit") + assert flow_rate_state.state == UnitOfVolumeFlowRate.LITERS_PER_SECOND + + # Select cubic meters per hour + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_flow_rate_unit", + ATTR_OPTION: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + }, + blocking=True, + ) + + # Verify API was called with "m3/h" (not Unicode m³/h) + mock_pooldose_client.set_select.assert_called_once_with("flow_rate_unit", "m3/h") + + # Verify state updated to m³/h (with Unicode) + flow_rate_state = hass.states.get("select.pool_device_flow_rate_unit") + assert flow_rate_state.state == UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR + + +@pytest.mark.usefixtures("init_integration") +async def test_select_option_no_conversion( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting an option without unit conversion.""" + # Verify initial state + ph_set_state = hass.states.get("select.pool_device_ph_dosing_set") + assert ph_set_state.state == "acid" + + # Select alkaline option + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_ph_dosing_set", + ATTR_OPTION: "alcalyne", + }, + blocking=True, + ) + + # Verify API was called with exact value + mock_pooldose_client.set_select.assert_called_once_with( + "ph_type_dosing_set", "alcalyne" + ) + + # Verify state updated + ph_set_state = hass.states.get("select.pool_device_ph_dosing_set") + assert ph_set_state.state == "alcalyne" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_dosing_method_options( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting different dosing method options.""" + # Test ORP dosing method + orp_method_state = hass.states.get("select.pool_device_orp_dosing_method") + assert orp_method_state.state == "on_off" + + # Change to proportional + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_orp_dosing_method", + ATTR_OPTION: "proportional", + }, + blocking=True, + ) + + # Verify API call + mock_pooldose_client.set_select.assert_called_once_with( + "orp_type_dosing_method", "proportional" + ) + + # Verify state + orp_method_state = hass.states.get("select.pool_device_orp_dosing_method") + assert orp_method_state.state == "proportional" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_dosing_set_high_low( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting high/low dosing intensity.""" + # Chlorine dosing set starts as high in fixture + cl_set_state = hass.states.get("select.pool_device_chlorine_dosing_set") + assert cl_set_state.state == "high" + + # Change to low + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_chlorine_dosing_set", + ATTR_OPTION: "low", + }, + blocking=True, + ) + + # Verify API call + mock_pooldose_client.set_select.assert_called_once_with("cl_type_dosing_set", "low") + + # Verify state + cl_set_state = hass.states.get("select.pool_device_chlorine_dosing_set") + assert cl_set_state.state == "low" diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py index abf64713f50..95cb5a7fe39 100644 --- a/tests/components/qbittorrent/test_config_flow.py +++ b/tests/components/qbittorrent/test_config_flow.py @@ -83,7 +83,7 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> result["flow_id"], USER_INPUT ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_URL: "http://localhost:8080", diff --git a/tests/components/qbus/test_config_flow.py b/tests/components/qbus/test_config_flow.py index 4f94f2bb277..1bc15e87396 100644 --- a/tests/components/qbus/test_config_flow.py +++ b/tests/components/qbus/test_config_flow.py @@ -45,7 +45,7 @@ async def test_step_discovery_confirm_create_entry( DOMAIN, context={"source": SOURCE_MQTT}, data=discovery ) - assert result.get("type") == FlowResultType.FORM + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "discovery_confirm" result = await hass.config_entries.flow.async_configure( @@ -53,7 +53,7 @@ async def test_step_discovery_confirm_create_entry( ) await hass.async_block_till_done() - assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("data") == { CONF_ID: "UL1", CONF_SERIAL_NUMBER: "000001", @@ -85,7 +85,7 @@ async def test_step_mqtt_invalid( DOMAIN, context={"source": SOURCE_MQTT}, data=discovery ) - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "invalid_discovery_info" @@ -117,7 +117,7 @@ async def test_handle_gateway_topic_when_online( ) assert mock_publish.called is mqtt_publish - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "discovery_in_progress" @@ -143,7 +143,7 @@ async def test_handle_config_topic( ) assert mock_publish.called - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "discovery_in_progress" @@ -162,7 +162,7 @@ async def test_handle_device_topic_missing_config(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_MQTT}, data=discovery ) - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "invalid_discovery_info" @@ -188,7 +188,7 @@ async def test_handle_device_topic_device_not_found( DOMAIN, context={"source": SOURCE_MQTT}, data=discovery ) - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "invalid_discovery_info" @@ -198,5 +198,5 @@ async def test_step_user_not_supported(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "not_supported" diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 6e76943f202..3c5cac1efc3 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -379,7 +379,7 @@ async def test_reauth_flow( result["flow_id"], {CONF_PASSWORD: "incorrect_password"}, ) - assert result.get("type") == FlowResultType.FORM + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "reauth_confirm" assert result.get("errors") == {"base": "invalid_auth"} @@ -388,7 +388,7 @@ async def test_reauth_flow( result["flow_id"], {CONF_PASSWORD: PASSWORD}, ) - assert result.get("type") == FlowResultType.ABORT + assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "reauth_successful" entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py index 8a5ce23799c..62a771df5cc 100644 --- a/tests/components/recorder/test_entity_registry.py +++ b/tests/components/recorder/test_entity_registry.py @@ -224,6 +224,7 @@ async def test_rename_entity_collision( hass.states.async_remove("sensor.test99") await hass.async_block_till_done() + await async_wait_recording_done(hass) # Rename entity sensor.test1 to sensor.test99 entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99") diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 0e712542f8d..c6bec904d11 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Callable +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -22,6 +23,7 @@ from homeassistant.components.reolink.const import ( BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, CONF_BC_PORT, + CONF_FIRMWARE_CHECK_TIME, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -47,6 +49,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format from homeassistant.setup import async_setup_component from .conftest import ( + CONF_BC_ONLY, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DEFAULT_PROTOCOL, @@ -58,6 +61,7 @@ from .conftest import ( TEST_MAC, TEST_MAC_CAM, TEST_NVR_NAME, + TEST_PASSWORD, TEST_PORT, TEST_PRIVACY, TEST_UID, @@ -146,10 +150,14 @@ async def test_firmware_error_twice( assert config_entry.state is ConfigEntryState.LOADED + freezer.tick(FIRMWARE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_firmware" assert hass.states.get(entity_id).state == STATE_OFF - freezer.tick(FIRMWARE_UPDATE_INTERVAL) + freezer.tick(2 * FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -968,7 +976,7 @@ async def test_privacy_mode_on( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED async def test_LoginPrivacyModeError( @@ -1130,6 +1138,53 @@ async def test_camera_wake_callback( assert hass.states.get(entity_id).state == STATE_OFF +@pytest.mark.parametrize(("seconds", "call_count"), [(10, 1), (3600, 0)]) +async def test_firmware_update_delay( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_host: MagicMock, + seconds: int, + call_count: int, +) -> None: + """Test delay of firmware update check.""" + now = datetime.now(UTC) + check_delay = ( + now + + timedelta(seconds=seconds) + - now.replace(hour=0, minute=0, second=0, microsecond=0) + ).total_seconds() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, + CONF_FIRMWARE_CHECK_TIME: check_delay, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_host.check_new_firmware.call_count == call_count + + async def test_baichaun_only( hass: HomeAssistant, reolink_host: MagicMock, diff --git a/tests/components/risco/test_services.py b/tests/components/risco/test_services.py new file mode 100644 index 00000000000..6bbd78e5774 --- /dev/null +++ b/tests/components/risco/test_services.py @@ -0,0 +1,110 @@ +"""Tests for the Risco services.""" + +from datetime import datetime +from unittest.mock import patch + +import pytest + +from homeassistant.components.risco import DOMAIN +from homeassistant.components.risco.const import SERVICE_SET_TIME +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .conftest import TEST_CLOUD_CONFIG + +from tests.common import MockConfigEntry + + +async def test_set_time_service( + hass: HomeAssistant, setup_risco_local, local_config_entry +) -> None: + """Test the set_time service.""" + with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock: + time_str = "2025-02-21T12:00:00" + time = datetime.fromisoformat(time_str) + data = { + ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id, + ATTR_TIME: time_str, + } + + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + mock.assert_called_once_with(time) + + +@pytest.mark.freeze_time("2025-02-21T12:00:00Z") +async def test_set_time_service_with_no_time( + hass: HomeAssistant, setup_risco_local, local_config_entry +) -> None: + """Test the set_time service when no time is provided.""" + with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock_set_time: + data = { + "config_entry_id": local_config_entry.entry_id, + } + + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + mock_set_time.assert_called_once_with(datetime.now()) + + +async def test_set_time_service_with_invalid_entry( + hass: HomeAssistant, setup_risco_local +) -> None: + """Test the set_time service with an invalid config entry.""" + data = { + ATTR_CONFIG_ENTRY_ID: "invalid_entry_id", + } + + with pytest.raises(ServiceValidationError, match="Config entry not found"): + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + +async def test_set_time_service_with_not_loaded_entry( + hass: HomeAssistant, setup_risco_local, local_config_entry +) -> None: + """Test the set_time service with a config entry that is not loaded.""" + await hass.config_entries.async_unload(local_config_entry.entry_id) + await hass.async_block_till_done() + + assert local_config_entry.state is ConfigEntryState.NOT_LOADED + + data = { + ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id, + } + + with pytest.raises(ServiceValidationError, match="is not loaded"): + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + +async def test_set_time_service_with_cloud_entry( + hass: HomeAssistant, setup_risco_local +) -> None: + """Test the set_time service with a cloud config entry.""" + cloud_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-cloud", + data=TEST_CLOUD_CONFIG, + ) + cloud_entry.add_to_hass(hass) + cloud_entry.mock_state(hass, ConfigEntryState.LOADED) + + data = { + ATTR_CONFIG_ENTRY_ID: cloud_entry.entry_id, + } + + with pytest.raises( + ServiceValidationError, match="This service only works with local" + ): + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index aaf9a69e112..0378ad98cba 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -66,6 +66,7 @@ from .mock_data import ( MAP_DATA, MULTI_MAP_LIST, NETWORK_INFO_BY_DEVICE, + Q7_B01_PROPS, ROBOROCK_RRUID, ROOM_MAPPING, SCENES, @@ -106,6 +107,13 @@ def create_zeo_trait() -> Mock: return zeo_trait +def create_b01_q7_trait() -> Mock: + """Create B01 Q7 trait for B01 devices.""" + b01_trait = AsyncMock() + b01_trait.query_values.return_value = Q7_B01_PROPS + return b01_trait + + @pytest.fixture(name="bypass_api_client_fixture") def bypass_api_client_fixture() -> None: """Skip calls to the API client.""" @@ -332,6 +340,8 @@ def fake_devices_fixture() -> list[FakeDevice]: fake_device.zeo = create_zeo_trait() else: raise ValueError("Unknown A01 category in test HOME_DATA") + elif device_data.pv == "B01": + fake_device.b01_q7_properties = create_b01_q7_trait() else: raise ValueError("Unknown pv in test HOME_DATA") devices.append(fake_device) diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index c9cd219e35b..80a51dff45d 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -4,6 +4,7 @@ from __future__ import annotations from PIL import Image from roborock.data import ( + B01Props, CleanRecord, CleanSummary, Consumable, @@ -15,6 +16,7 @@ from roborock.data import ( S7Status, UserData, ValleyElectricityTimer, + WorkStatusMapping, ) from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.map_data import ImageData @@ -530,6 +532,239 @@ HOME_DATA_RAW = { }, ], }, + { + "id": "q7_product_id", + "name": "Roborock Q7 Series", + "model": "roborock.vacuum.sc01", + "category": "robot.vacuum.cleaner", + "capability": 0, + "schema": [ + { + "id": 101, + "name": "RPC Request", + "code": "rpc_request", + "mode": "rw", + "type": "RAW", + "property": "null", + }, + { + "id": 102, + "name": "RPC Response", + "code": "rpc_response", + "mode": "rw", + "type": "RAW", + "property": "null", + }, + { + "id": 120, + "name": "错误代码", + "code": "error_code", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 121, + "name": "设备状态", + "code": "state", + "mode": "ro", + "type": "VALUE", + "property": "null", + }, + { + "id": 122, + "name": "设备电量", + "code": "battery", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 123, + "name": "吸力档位", + "code": "fan_power", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 124, + "name": "拖地档位", + "code": "water_box_mode", + "mode": "rw", + "type": "RAW", + "property": "null", + }, + { + "id": 125, + "name": "主刷寿命", + "code": "main_brush_life", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 126, + "name": "边刷寿命", + "code": "side_brush_life", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 127, + "name": "滤网寿命", + "code": "filter_life", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 135, + "name": "离线原因", + "code": "offline_status", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 136, + "name": "清洁次数", + "code": "clean_times", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 137, + "name": "扫拖模式", + "code": "cleaning_preference", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 138, + "name": "清洁任务类型", + "code": "clean_task_type", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 139, + "name": "返回基站类型", + "code": "back_type", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 141, + "name": "清洁进度", + "code": "cleaning_progress", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 142, + "name": "窜货信息", + "code": "fc_state", + "mode": "ro", + "type": "RAW", + "property": "null", + }, + { + "id": 201, + "name": "启动清洁任务", + "code": "start_clean_task", + "mode": "wo", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 202, + "name": "返回基站任务", + "code": "start_back_dock_task", + "mode": "wo", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 203, + "name": "启动基站任务", + "code": "start_dock_task", + "mode": "wo", + "type": "ENUM", + "property": '{"range": []}', + }, + { + "id": 204, + "name": "暂停任务", + "code": "pause", + "mode": "wo", + "type": "RAW", + "property": "null", + }, + { + "id": 205, + "name": "继续任务", + "code": "resume", + "mode": "wo", + "type": "RAW", + "property": "null", + }, + { + "id": 206, + "name": "结束任务", + "code": "stop", + "mode": "wo", + "type": "RAW", + "property": "null", + }, + { + "id": 10000, + "name": "request_cmd", + "code": "request_cmd", + "mode": "wo", + "type": "RAW", + "property": "null", + }, + { + "id": 10001, + "name": "response_cmd", + "code": "response_cmd", + "mode": "ro", + "type": "RAW", + "property": "null", + }, + { + "id": 10002, + "name": "request_map", + "code": "request_map", + "mode": "ro", + "type": "RAW", + "property": "null", + }, + { + "id": 10003, + "name": "response_map", + "code": "response_map", + "mode": "ro", + "type": "RAW", + "property": "null", + }, + { + "id": 10004, + "name": "event_report", + "code": "event_report", + "mode": "rw", + "type": "RAW", + "property": "null", + }, + ], + }, { "id": "zeo_id", "name": "Zeo One", @@ -951,6 +1186,45 @@ HOME_DATA_RAW = { "silentOtaSwitch": False, "f": False, }, + { + "duid": "q7_duid", + "name": "Roborock Q7", + "localKey": "q7_local_key", + "productId": "q7_product_id", + "fv": "03.01.71", + "activeTime": 1749513705, + "timeZoneId": "Pacific/Auckland", + "iconUrl": "", + "share": True, + "shareTime": 1754789238, + "online": True, + "pv": "B01", + "tuyaMigrated": False, + "extra": '{"1749518432": "0", "1753581557": "0", "clean_finish": "{}"}', + "sn": "q7_sn", + "deviceStatus": { + "135": 0, + "120": 0, + "121": 8, + "122": 100, + "123": 4, + "124": 2, + "125": 77, + "126": 4294965348, + "127": 54, + "136": 1, + "137": 1, + "138": 0, + "139": 0, + "141": 0, + "142": 0, + }, + "silentOtaSwitch": False, + "f": False, + "createTime": 1749513706, + "cid": "DE", + "shareType": "UNLIMITED_TIME", + }, { "duid": "zeo_duid", "name": "Zeo One", @@ -1209,3 +1483,13 @@ SCENES = [ }, ), ] + +Q7_B01_PROPS = B01Props( + status=WorkStatusMapping.SWEEP_MOPING, + main_brush=5000, + side_brush=3000, + hypa=1500, + main_sensor=500, + mop_life=1200, + real_clean_time=3000, +) diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 55e8af1f859..6cc9db53a6f 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -1172,6 +1172,280 @@ ]), }), }), + '**REDACTED-4**': dict({ + 'device': dict({ + 'activeTime': 1749513705, + 'cid': 'DE', + 'createTime': 1749513706, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 4, + '124': 2, + '125': 77, + '126': 4294965348, + '127': 54, + '135': 0, + '136': 1, + '137': 1, + '138': 0, + '139': 0, + '141': 0, + '142': 0, + }), + 'duid': '**REDACTED**', + 'extra': '{"1749518432": "0", "1753581557": "0", "clean_finish": "{}"}', + 'f': False, + 'fv': '03.01.71', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock Q7', + 'online': True, + 'productId': 'q7_product_id', + 'pv': 'B01', + 'share': True, + 'shareTime': 1754789238, + 'shareType': 'UNLIMITED_TIME', + 'silentOtaSwitch': False, + 'sn': '**REDACTED**', + 'timeZoneId': 'Pacific/Auckland', + 'tuyaMigrated': False, + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'id': 'q7_product_id', + 'model': 'roborock.vacuum.sc01', + 'name': 'Roborock Q7 Series', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': 101, + 'mode': 'rw', + 'name': 'RPC Request', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': 102, + 'mode': 'rw', + 'name': 'RPC Response', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': 120, + 'mode': 'ro', + 'name': '错误代码', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': 121, + 'mode': 'ro', + 'name': '设备状态', + 'property': 'null', + 'type': 'VALUE', + }), + dict({ + 'code': 'battery', + 'id': 122, + 'mode': 'ro', + 'name': '设备电量', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': 123, + 'mode': 'rw', + 'name': '吸力档位', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': 124, + 'mode': 'rw', + 'name': '拖地档位', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'main_brush_life', + 'id': 125, + 'mode': 'ro', + 'name': '主刷寿命', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'side_brush_life', + 'id': 126, + 'mode': 'ro', + 'name': '边刷寿命', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'filter_life', + 'id': 127, + 'mode': 'ro', + 'name': '滤网寿命', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'offline_status', + 'id': 135, + 'mode': 'ro', + 'name': '离线原因', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'clean_times', + 'id': 136, + 'mode': 'rw', + 'name': '清洁次数', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'cleaning_preference', + 'id': 137, + 'mode': 'rw', + 'name': '扫拖模式', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'clean_task_type', + 'id': 138, + 'mode': 'ro', + 'name': '清洁任务类型', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'back_type', + 'id': 139, + 'mode': 'ro', + 'name': '返回基站类型', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'cleaning_progress', + 'id': 141, + 'mode': 'ro', + 'name': '清洁进度', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'fc_state', + 'id': 142, + 'mode': 'ro', + 'name': '窜货信息', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'start_clean_task', + 'id': 201, + 'mode': 'wo', + 'name': '启动清洁任务', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'start_back_dock_task', + 'id': 202, + 'mode': 'wo', + 'name': '返回基站任务', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'start_dock_task', + 'id': 203, + 'mode': 'wo', + 'name': '启动基站任务', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'pause', + 'id': 204, + 'mode': 'wo', + 'name': '暂停任务', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'resume', + 'id': 205, + 'mode': 'wo', + 'name': '继续任务', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'stop', + 'id': 206, + 'mode': 'wo', + 'name': '结束任务', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'request_cmd', + 'id': 10000, + 'mode': 'wo', + 'name': 'request_cmd', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'response_cmd', + 'id': 10001, + 'mode': 'ro', + 'name': 'response_cmd', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'request_map', + 'id': 10002, + 'mode': 'ro', + 'name': 'request_map', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'response_map', + 'id': 10003, + 'mode': 'ro', + 'name': 'response_map', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'event_report', + 'id': 10004, + 'mode': 'rw', + 'name': 'event_report', + 'property': 'null', + 'type': 'RAW', + }), + ]), + }), + }), }), }) # --- diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 61f7a1066d7..bdf797a079e 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -878,5 +878,108 @@ 'last_updated': , 'state': 'none', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock Q7 Status', + 'options': list([ + 'sleeping', + 'waiting_for_orders', + 'paused', + 'docking', + 'charging', + 'sweep_moping', + 'sweep_moping_2', + 'moping', + 'updating', + 'mop_cleaning', + 'mop_airdrying', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sweep_moping', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Main brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_main_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.666666666667', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Side brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_side_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Filter time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_filter_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '125.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Sensor time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_sensor_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.6666666666667', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Mop life time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_mop_life_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '160.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }), ]) # --- diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 56889c84a82..dfcc9a68b5c 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -213,7 +213,7 @@ async def test_options_flow_drawables( mock_roborock_entry.entry_id ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == DRAWABLES with patch( "homeassistant.components.roborock.async_setup_entry", return_value=True @@ -224,7 +224,7 @@ async def test_options_flow_drawables( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_roborock_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 4773badbe14..034d8b3c1f9 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,19 +1,27 @@ """Test for Roborock init.""" +import datetime import pathlib from typing import Any from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from roborock import ( RoborockInvalidCredentials, RoborockInvalidUserAgreement, RoborockNoUserAgreement, ) +from roborock.exceptions import RoborockException +from roborock.mqtt.session import MqttSessionUnauthorized +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry @@ -22,7 +30,7 @@ from homeassistant.setup import async_setup_component from .conftest import FakeDevice from .mock_data import ROBOROCK_RRUID, USER_EMAIL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -63,13 +71,18 @@ async def test_home_assistant_stop( assert device_manager.close.called +@pytest.mark.parametrize( + "side_effect", [RoborockInvalidCredentials(), MqttSessionUnauthorized()] +) async def test_reauth_started( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + side_effect: Exception, ) -> None: """Test reauth flow started.""" with patch( "homeassistant.components.roborock.create_device_manager", - side_effect=RoborockInvalidCredentials(), + side_effect=side_effect, ): await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -220,6 +233,7 @@ async def test_stale_device( "Roborock S7 2 Dock", "Dyad Pro", "Zeo One", + "Roborock Q7", } fake_devices.pop(0) # Remove one robot @@ -233,6 +247,7 @@ async def test_stale_device( "Roborock S7 2 Dock", "Dyad Pro", "Zeo One", + "Roborock Q7", } @@ -256,6 +271,7 @@ async def test_no_stale_device( "Roborock S7 2 Dock", "Dyad Pro", "Zeo One", + "Roborock Q7", } await hass.config_entries.async_reload(mock_roborock_entry.entry_id) @@ -270,6 +286,7 @@ async def test_no_stale_device( "Roborock S7 2 Dock", "Dyad Pro", "Zeo One", + "Roborock Q7", } @@ -294,6 +311,72 @@ async def test_migrate_config_entry_unique_id( assert config_entry.unique_id == ROBOROCK_RRUID +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_update_unavailability_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_entry: MockConfigEntry, + fake_vacuum: FakeDevice, +) -> None: + """Test that a small number of update failures are suppressed before marking a device unavailable.""" + await async_setup_component(hass, HA_DOMAIN, {}) + assert setup_entry.state is ConfigEntryState.LOADED + + # We pick an arbitrary sensor to test for availability + sensor_entity_id = "sensor.roborock_s7_maxv_battery" + expected_state = "100" + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + # Simulate a few update failures below the threshold + assert fake_vacuum.v1_properties is not None + fake_vacuum.v1_properties.status.refresh.side_effect = RoborockException( + "Simulated update failure" + ) + + # Move forward in time less than the threshold + freezer.tick(datetime.timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: sensor_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify that the entity is still available + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + # Move forward in time to exceed the threshold + freezer.tick(datetime.timedelta(minutes=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify that the entity is now unavailable + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == "unavailable" + + # Now restore normal update behavior and refresh. + fake_vacuum.v1_properties.status.refresh.side_effect = None + + freezer.tick(datetime.timedelta(seconds=45)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify that the entity recovers and is available again + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + async def test_cloud_api_repair( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, @@ -330,3 +413,73 @@ async def test_cloud_api_repair( await hass.async_block_till_done() assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_zeo_device_fails_setup( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, + fake_devices: list[FakeDevice], +) -> None: + """Simulate an error while setting up a zeo device.""" + # We have a single zeo device in the test setup. Find it then set it to fail. + zeo_device = next( + (device for device in fake_devices if device.zeo is not None), + None, + ) + assert zeo_device is not None + zeo_device.zeo.query_values.side_effect = RoborockException("Simulated Zeo failure") + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + + # The current behavior is that we do not add the Zeo device if it fails to setup + found_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert {device.name for device in found_devices} == { + "Roborock S7 MaxV", + "Roborock S7 MaxV Dock", + "Roborock S7 2", + "Roborock S7 2 Dock", + "Dyad Pro", + "Roborock Q7", + # Zeo device is missing + } + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_dyad_device_fails_setup( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, + fake_devices: list[FakeDevice], +) -> None: + """Simulate an error while setting up a dyad device.""" + # We have a single dyad device in the test setup. Find it then set it to fail. + dyad_device = next( + (device for device in fake_devices if device.dyad is not None), + None, + ) + assert dyad_device is not None + dyad_device.dyad.query_values.side_effect = RoborockException( + "Simulated Dyad failure" + ) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + + # The current behavior is that we do not add the Dyad device if it fails to setup + found_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert {device.name for device in found_devices} == { + "Roborock S7 MaxV", + "Roborock S7 MaxV Dock", + "Roborock S7 2", + "Roborock S7 2 Dock", + # Dyad device is missing + "Zeo One", + "Roborock Q7", + } diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 88c639b4b83..8c05e13b1b0 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, call import pytest from roborock import RoborockCommand +from roborock.data.v1 import RoborockDockDustCollectionModeCode from roborock.exceptions import RoborockException from homeassistant.components.roborock import DOMAIN @@ -154,3 +155,30 @@ async def test_selected_map_without_name( select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") assert select_entity assert select_entity.state == "Map 0" + + +@pytest.mark.parametrize( + ("dust_collection_mode", "expected_state"), + [ + (None, "unknown"), + (RoborockDockDustCollectionModeCode.smart, "smart"), + (RoborockDockDustCollectionModeCode.light, "light"), + ], +) +async def test_dust_collection_mode_none( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, + dust_collection_mode: RoborockDockDustCollectionModeCode | None, + expected_state: str, +) -> None: + """Test that the dust collection mode entity correctly handles mode values.""" + assert fake_vacuum.v1_properties + assert fake_vacuum.v1_properties.dust_collection_mode + fake_vacuum.v1_properties.dust_collection_mode.mode = dust_collection_mode + + await async_setup_component(hass, DOMAIN, {}) + + select_entity = hass.states.get("select.roborock_s7_maxv_dock_empty_mode") + assert select_entity + assert select_entity.state == expected_state diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 08189a125e9..35f30bc7a10 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -2160,7 +2160,7 @@ async def test_dhcp_while_user_flow_pending(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER}, ) - assert result_user["type"] == FlowResultType.FORM + assert result_user["type"] is FlowResultType.FORM assert result_user["step_id"] == "user" # While user flow is pending (form shown), trigger DHCP flow @@ -2178,4 +2178,4 @@ async def test_dhcp_while_user_flow_pending(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data, ) - assert result_dhcp["type"] == FlowResultType.ABORT + assert result_dhcp["type"] is FlowResultType.ABORT diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b2cedfbe4b7..2d74c14c1fe 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2261,8 +2261,8 @@ async def test_compile_hourly_sum_statistics_total_increasing_small_dip( last_updated = states["sensor.test1"][6].last_updated.isoformat() assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " - f"strictly increasing. Triggered by state {state} ({previous_state}) with " - f"last_updated set to {last_updated}. Please create a bug report at " + f"strictly increasing. Triggered by state {state} (previous state: {previous_state}) " + f"with last_updated set to {last_updated}. Please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) in caplog.text statistic_ids = await async_list_statistic_ids(hass) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py new file mode 100644 index 00000000000..2324b01ab02 --- /dev/null +++ b/tests/components/shelly/test_services.py @@ -0,0 +1,293 @@ +"""Tests for Shelly services.""" + +from unittest.mock import Mock + +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import pytest + +from homeassistant.components.shelly.const import ATTR_KEY, ATTR_VALUE, DOMAIN +from homeassistant.components.shelly.services import ( + SERVICE_GET_KVS_VALUE, + SERVICE_SET_KVS_VALUE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_service_get_kvs_value( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test get_kvs_value service.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + mock_rpc_device.kvs_get.return_value = { + "etag": "16mLia9TRt8lGhj9Zf5Dp6Hw==", + "value": "test_value", + } + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert response == {"value": "test_value"} + mock_rpc_device.kvs_get.assert_called_once_with("test_key") + + +async def test_service_get_kvs_value_invalid_device(hass: HomeAssistant) -> None: + """Test get_kvs_value service with invalid device ID.""" + await init_integration(hass, 2) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: "invalid_device_id", ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_device_id" + assert exc_info.value.translation_placeholders == { + ATTR_DEVICE_ID: "invalid_device_id" + } + + +async def test_service_get_kvs_value_block_device( + hass: HomeAssistant, mock_block_device: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test get_kvs_value service with non-RPC (Gen1) device.""" + entry = await init_integration(hass, 1) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "kvs_not_supported" + assert exc_info.value.translation_placeholders == {"device": entry.title} + + +@pytest.mark.parametrize( + ("exc", "translation_key"), + [ + (RpcCallError(999), "rpc_call_error"), + (DeviceConnectionError, "device_communication_error"), + ], +) +async def test_service_get_kvs_value_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, + exc: Exception, + translation_key: str, +) -> None: + """Test get_kvs_value service with exception.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + mock_rpc_device.kvs_get.side_effect = exc + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Test config entry not loaded.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "entry_not_loaded" + assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_service_get_kvs_value_sleeping_device( + hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test get_kvs_value service with RPC sleeping device.""" + entry = await init_integration(hass, 2, sleep_period=1000) + + # Make device online + mock_rpc_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "kvs_not_supported" + assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_service_set_kvs_value( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test set_kvs_value service.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key", ATTR_VALUE: "test_value"}, + blocking=True, + ) + + mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") + + +async def test_service_get_kvs_value_config_entry_not_found( + hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test device with no config entries.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + # Remove all config entries from device + device_registry.devices[device.id].config_entries.clear() + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "config_entry_not_found" + assert exc_info.value.translation_placeholders == {"device_id": device.id} + + +async def test_service_get_kvs_value_device_not_initialized( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_kvs_value if runtime_data.rpc is None.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + monkeypatch.delattr(entry.runtime_data, "rpc") + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "device_not_initialized" + assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_service_get_kvs_value_wrong_domain( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test get_kvs_value when device has config entries from different domains.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + # Create a config entry with different domain and add it to the device + other_entry = MockConfigEntry( + domain="other_domain", + data={}, + ) + other_entry.add_to_hass(hass) + + # Add the other domain's config entry to the device + device_registry.async_update_device( + device.id, add_config_entry_id=other_entry.entry_id + ) + + # Remove the original Shelly config entry + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "config_entry_not_found" + assert exc_info.value.translation_placeholders == {"device_id": device.id} diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index f13da7198bc..63130c5cf35 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -175,7 +175,7 @@ async def test_dhcp_already_configured_duplicate( data=DHCP_DISCOVERY_DUPLICATE_001, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" await hass.async_block_till_done() diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index b9af2605f1d..0b600387033 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -410,6 +410,65 @@ 'state': '2.0', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][number.lodowka_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.lodowka_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_onedoor_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][number.lodowka_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Lodówka Target temperature', + 'max': 7, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.lodowka_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index cbec75b3a86..4abe995c6b9 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -9367,6 +9367,62 @@ 'state': '0.00027936416665713', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lodowka_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_onedoor_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Lodówka Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index 2567b793bc2..5b02dfd62b0 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -78,4 +78,4 @@ async def test_migrate_from_future_version( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.state == ConfigEntryState.MIGRATION_ERROR + assert entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py index 9e07f011cd4..343aee8095c 100644 --- a/tests/components/snoo/test_config_flow.py +++ b/tests/components/snoo/test_config_flow.py @@ -33,7 +33,7 @@ async def test_config_flow_success( }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" assert result["data"] == { CONF_USERNAME: "test-username", @@ -72,7 +72,7 @@ async def test_form_auth_issues( }, ) # Reset auth back to the original - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error_msg} bypass_api.authorize.side_effect = None result = await hass.config_entries.flow.async_configure( @@ -83,7 +83,7 @@ async def test_form_auth_issues( }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" assert result["data"] == { CONF_USERNAME: "test-username", diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py index 72c4b6fb8ab..34cd9243429 100644 --- a/tests/components/snoo/test_init.py +++ b/tests/components/snoo/test_init.py @@ -15,7 +15,7 @@ async def test_async_setup_entry(hass: HomeAssistant, bypass_api: AsyncMock) -> """Test a successful setup entry.""" entry = await async_init_integration(hass) assert len(hass.states.async_all("sensor")) == 2 - assert entry.state == ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED async def test_cannot_auth(hass: HomeAssistant, bypass_api: AsyncMock) -> None: diff --git a/tests/components/solarlog/test_init.py b/tests/components/solarlog/test_init.py index a9a595f8962..97a247015db 100644 --- a/tests/components/solarlog/test_init.py +++ b/tests/components/solarlog/test_init.py @@ -62,7 +62,7 @@ async def test_setup_error( assert mock_config_entry.state == error - if error == ConfigEntryState.SETUP_RETRY: + if error is ConfigEntryState.SETUP_RETRY: assert len(hass.config_entries.flow.async_progress()) == 0 @@ -117,7 +117,7 @@ async def test_other_exceptions_during_first_refresh( await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) await hass.async_block_till_done() - assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert len(hass.config_entries.flow.async_progress()) == 0 diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index cdb7be15589..c96805779f8 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from soco.exceptions import SoCoException from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, @@ -189,3 +190,35 @@ async def test_zgs_avtransport_group_speakers( await _media_play(hass, "media_player.living_room") assert soco_lr.play.call_count == 1 assert soco_br.play.call_count == 0 + + +async def test_async_offline_without_subscription_lock( + hass: HomeAssistant, + config_entry: MockConfigEntry, + soco: MockSoCo, +) -> None: + """Test unloading entry works when subscription lock was never created. + + This can happen when a speaker is discovered but setup() fails early + before async_setup() is scheduled. The integration should handle this + gracefully during unload. + """ + # Make play_mode raise an exception to cause setup() to fail early. + # The speaker is added to discovered before setup() is called in _add_speaker, + # so this creates a speaker in discovered without _subscription_lock being created. + # Using PropertyMock to only affect this specific test's soco instance. + with patch.object( + type(soco), + "play_mode", + new_callable=lambda: property( + fget=lambda self: (_ for _ in ()).throw(SoCoException("Connection failed")) + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + # Unload should succeed without AssertionError even though + # _subscription_lock was never created + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index edd65b36bec..1b0aa98996a 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -404,6 +404,62 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result["context"]["unique_id"] == UUID +async def test_form_missing_uuid(hass: HomeAssistant) -> None: + """Test we handle cannot connect error, then succeed after retry.""" + + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "edit"} + ) + assert result["type"] is FlowResultType.FORM + + # First attempt: simulate cannot connect + with patch( + "pysqueezebox.Server.async_query", + return_value={"some_other_key": "some_value"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + + # We should still be in a form, with an error + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_uuid"} + + # Second attempt: simulate a successful connection + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST # the flow uses host as title + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } + assert result["context"]["unique_id"] == UUID + + async def test_discovery(hass: HomeAssistant) -> None: """Test handling of discovered server, then completing the flow.""" diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index fd82e688ee0..7f0ba1bd206 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -316,7 +316,7 @@ async def test_config_flow_preview_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] is None @@ -335,7 +335,7 @@ async def test_config_flow_preview_success( }, ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" assert result["errors"] is None assert result["preview"] == "statistics" @@ -390,7 +390,7 @@ async def test_options_flow_preview( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "statistics" @@ -452,7 +452,7 @@ async def test_options_flow_sensor_preview_config_entry_removed( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "statistics" diff --git a/tests/components/sunricher_dali/__init__.py b/tests/components/sunricher_dali/__init__.py index aa944423da6..9d162f03da4 100644 --- a/tests/components/sunricher_dali/__init__.py +++ b/tests/components/sunricher_dali/__init__.py @@ -16,3 +16,9 @@ def find_device_listener( raise AssertionError( f"Listener for event type {event_type} not found on device {device.dev_id}" ) + + +def trigger_availability_callback(device: MagicMock, available: bool) -> None: + """Trigger availability callbacks registered on the device mock.""" + callback = find_device_listener(device, CallbackEventType.ONLINE_STATUS) + callback(available) diff --git a/tests/components/sunricher_dali/conftest.py b/tests/components/sunricher_dali/conftest.py index 813a81bdd17..338e82f293b 100644 --- a/tests/components/sunricher_dali/conftest.py +++ b/tests/components/sunricher_dali/conftest.py @@ -1,8 +1,10 @@ """Common fixtures for the Sunricher DALI tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from PySrDaliGateway.helper import gen_device_unique_id, gen_group_unique_id import pytest from homeassistant.components.sunricher_dali.const import CONF_SERIAL_NUMBER, DOMAIN @@ -12,10 +14,73 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + Platform, ) +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +GATEWAY_SERIAL = "6A242121110E" +GATEWAY_HOST = "192.168.1.100" +GATEWAY_PORT = 1883 + +DEVICE_DATA: list[dict[str, Any]] = [ + { + "dev_id": "01010000026A242121110E", + "dev_type": "0101", + "name": "Dimmer 0000-02", + "model": "DALI DT6 Dimmable Driver", + "color_mode": "brightness", + "address": 2, + "channel": 0, + }, + { + "dev_id": "01020000036A242121110E", + "dev_type": "0102", + "name": "CCT 0000-03", + "model": "DALI DT8 Tc Dimmable Driver", + "color_mode": "color_temp", + "address": 3, + "channel": 0, + }, + { + "dev_id": "01030000046A242121110E", + "dev_type": "0103", + "name": "HS Color Light", + "model": "DALI HS Color Driver", + "color_mode": "hs", + "address": 4, + "channel": 0, + }, + { + "dev_id": "01040000056A242121110E", + "dev_type": "0104", + "name": "RGBW Light", + "model": "DALI RGBW Driver", + "color_mode": "rgbw", + "address": 5, + "channel": 0, + }, +] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, + mock_devices: list[MagicMock], + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.sunricher_dali._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -23,36 +88,29 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data={ - CONF_SERIAL_NUMBER: "6A242121110E", - CONF_HOST: "192.168.1.100", - CONF_PORT: 1883, + CONF_SERIAL_NUMBER: GATEWAY_SERIAL, + CONF_HOST: GATEWAY_HOST, + CONF_PORT: GATEWAY_PORT, CONF_NAME: "Test Gateway", CONF_USERNAME: "gateway_user", CONF_PASSWORD: "gateway_pass", }, - unique_id="6A242121110E", + unique_id=GATEWAY_SERIAL, title="Test Gateway", ) -def _create_mock_device( - dev_id: str, - dev_type: str, - name: str, - model: str, - color_mode: str, - gw_sn: str = "6A242121110E", -) -> MagicMock: - """Create a mock device with standard attributes.""" +def _create_mock_device(device_data: dict[str, Any]) -> MagicMock: + """Create a mock device from device data dict.""" device = MagicMock() - device.dev_id = dev_id - device.unique_id = dev_id + device.dev_id = device_data["dev_id"] + device.unique_id = device_data["dev_id"] device.status = "online" - device.dev_type = dev_type - device.name = name - device.model = model - device.gw_sn = gw_sn - device.color_mode = color_mode + device.dev_type = device_data["dev_type"] + device.name = device_data["name"] + device.model = device_data["model"] + device.gw_sn = GATEWAY_SERIAL + device.color_mode = device_data["color_mode"] device.turn_on = MagicMock() device.turn_off = MagicMock() device.read_status = MagicMock() @@ -63,43 +121,23 @@ def _create_mock_device( @pytest.fixture def mock_devices() -> list[MagicMock]: """Return mocked Device objects.""" - return [ - _create_mock_device( - "01010000026A242121110E", - "0101", - "Dimmer 0000-02", - "DALI DT6 Dimmable Driver", - "brightness", - ), - _create_mock_device( - "01020000036A242121110E", - "0102", - "CCT 0000-03", - "DALI DT8 Tc Dimmable Driver", - "color_temp", - ), - _create_mock_device( - "01030000046A242121110E", - "0103", - "HS Color Light", - "DALI HS Color Driver", - "hs", - ), - _create_mock_device( - "01040000056A242121110E", - "0104", - "RGBW Light", - "DALI RGBW Driver", - "rgbw", - ), - _create_mock_device( - "01010000026A242121110E", - "0101", - "Duplicate Dimmer", - "DALI DT6 Dimmable Driver", - "brightness", - ), - ] + devices = [_create_mock_device(data) for data in DEVICE_DATA] + devices.append(_create_mock_device(DEVICE_DATA[0])) + return devices + + +def _create_scene_device_property( + dev_type: str, brightness: int = 128, **kwargs: Any +) -> dict[str, Any]: + """Create scene device property dict with defaults.""" + return { + "is_on": True, + "brightness": brightness, + "color_temp_kelvin": kwargs.get("color_temp_kelvin"), + "hs_color": kwargs.get("hs_color"), + "rgbw_color": kwargs.get("rgbw_color"), + "white_level": kwargs.get("white_level"), + } @pytest.fixture @@ -113,8 +151,105 @@ def mock_discovery(mock_gateway: MagicMock) -> Generator[MagicMock]: yield mock_discovery +def _create_mock_scene( + scene_id: int, + name: str, + unique_id: str, + channel: int, + area_id: str, + devices: list[dict[str, Any]], + gw_sn: str = GATEWAY_SERIAL, +) -> MagicMock: + """Create a mock scene with standard attributes.""" + devices_with_ids: list[dict[str, Any]] = [] + for device in devices: + device_with_id = dict(device) + device_with_id["unique_id"] = ( + gen_group_unique_id(device["address"], device["channel"], gw_sn) + if device["dev_type"] == "0401" + else gen_device_unique_id( + device["dev_type"], + device["channel"], + device["address"], + gw_sn, + ) + ) + devices_with_ids.append(device_with_id) + + scene = MagicMock() + scene.scene_id = scene_id + scene.name = name + scene.unique_id = unique_id + scene.gw_sn = gw_sn + scene.channel = channel + scene.activate = MagicMock() + scene.devices = devices_with_ids + + scene_details: dict[str, Any] = { + "unique_id": unique_id, + "id": scene_id, + "name": name, + "channel": channel, + "area_id": area_id, + "devices": devices_with_ids, + } + scene.read_scene = AsyncMock(return_value=scene_details) + scene.register_listener = MagicMock(return_value=lambda: None) + return scene + + @pytest.fixture -def mock_gateway(mock_devices: list[MagicMock]) -> Generator[MagicMock]: +def mock_scenes() -> list[MagicMock]: + """Return mocked Scene objects.""" + return [ + _create_mock_scene( + scene_id=1, + name="Living Room Evening", + unique_id=f"scene_0001_0000_{GATEWAY_SERIAL}", + channel=0, + area_id="1", + devices=[ + { + "dev_type": DEVICE_DATA[0]["dev_type"], + "channel": DEVICE_DATA[0]["channel"], + "address": DEVICE_DATA[0]["address"], + "gw_sn_obj": "", + "property": _create_scene_device_property("0101", brightness=128), + }, + { + "dev_type": DEVICE_DATA[1]["dev_type"], + "channel": DEVICE_DATA[1]["channel"], + "address": DEVICE_DATA[1]["address"], + "gw_sn_obj": "", + "property": _create_scene_device_property( + "0102", brightness=200, color_temp_kelvin=3000 + ), + }, + ], + ), + _create_mock_scene( + scene_id=2, + name="Kitchen Bright", + unique_id=f"scene_0002_0000_{GATEWAY_SERIAL}", + channel=0, + area_id="2", + devices=[ + { + "dev_type": "0401", + "channel": 0, + "address": 1, + "gw_sn_obj": "", + "property": _create_scene_device_property("0401", brightness=255), + }, + ], + ), + ] + + +@pytest.fixture +def mock_gateway( + mock_devices: list[MagicMock], mock_scenes: list[MagicMock] +) -> Generator[MagicMock]: """Return a mocked DaliGateway.""" with ( patch( @@ -126,15 +261,16 @@ def mock_gateway(mock_devices: list[MagicMock]) -> Generator[MagicMock]: ), ): mock_gateway = mock_gateway_class.return_value - mock_gateway.gw_sn = "6A242121110E" - mock_gateway.gw_ip = "192.168.1.100" - mock_gateway.port = 1883 + mock_gateway.gw_sn = GATEWAY_SERIAL + mock_gateway.gw_ip = GATEWAY_HOST + mock_gateway.port = GATEWAY_PORT mock_gateway.name = "Test Gateway" mock_gateway.username = "gateway_user" mock_gateway.passwd = "gateway_pass" mock_gateway.connect = AsyncMock() mock_gateway.disconnect = AsyncMock() mock_gateway.discover_devices = AsyncMock(return_value=mock_devices) + mock_gateway.discover_scenes = AsyncMock(return_value=mock_scenes) yield mock_gateway diff --git a/tests/components/sunricher_dali/snapshots/test_init.ambr b/tests/components/sunricher_dali/snapshots/test_init.ambr new file mode 100644 index 00000000000..ca94d3b5dff --- /dev/null +++ b/tests/components/sunricher_dali/snapshots/test_init.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '6a:24:21:21:11:0e', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunricher_dali', + '6A242121110E', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunricher', + 'model': 'SR-GW-EDA', + 'model_id': None, + 'name': 'Test Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '6A242121110E', + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunricher_dali', + '01010000026A242121110E', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunricher', + 'model': 'DALI DT6 Dimmable Driver', + 'model_id': None, + 'name': 'Dimmer 0000-02', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunricher_dali', + '01020000036A242121110E', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunricher', + 'model': 'DALI DT8 Tc Dimmable Driver', + 'model_id': None, + 'name': 'CCT 0000-03', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunricher_dali', + '01030000046A242121110E', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunricher', + 'model': 'DALI HS Color Driver', + 'model_id': None, + 'name': 'HS Color Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunricher_dali', + '01040000056A242121110E', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunricher', + 'model': 'DALI RGBW Driver', + 'model_id': None, + 'name': 'RGBW Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/sunricher_dali/snapshots/test_scene.ambr b/tests/components/sunricher_dali/snapshots/test_scene.ambr new file mode 100644 index 00000000000..c4812ef51b2 --- /dev/null +++ b/tests/components/sunricher_dali/snapshots/test_scene.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entities[scene.test_gateway_kitchen_bright-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.test_gateway_kitchen_bright', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Bright', + 'platform': 'sunricher_dali', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'scene_0002_0000_6A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[scene.test_gateway_kitchen_bright-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Gateway Kitchen Bright', + }), + 'context': , + 'entity_id': 'scene.test_gateway_kitchen_bright', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[scene.test_gateway_living_room_evening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.test_gateway_living_room_evening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Evening', + 'platform': 'sunricher_dali', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'scene_0001_0000_6A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[scene.test_gateway_living_room_evening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Gateway Living Room Evening', + }), + 'context': , + 'entity_id': 'scene.test_gateway_living_room_evening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sunricher_dali/test_config_flow.py b/tests/components/sunricher_dali/test_config_flow.py index dc1a2ce73fc..b0ee879c76d 100644 --- a/tests/components/sunricher_dali/test_config_flow.py +++ b/tests/components/sunricher_dali/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from PySrDaliGateway.exceptions import DaliGatewayError from homeassistant.components.sunricher_dali.const import CONF_SERIAL_NUMBER, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -15,6 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -219,3 +220,43 @@ async def test_discovery_unique_id_already_configured( assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "already_configured" + + +async def test_dhcp_updates_existing_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery updates IP of existing entry.""" + mock_config_entry.add_to_hass(hass) + + assert mock_config_entry.data[CONF_HOST] != "192.168.1.200" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.200", + macaddress="6a242121110e", + hostname="dali-gateway", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.200" + + +async def test_dhcp_unknown_device(hass: HomeAssistant) -> None: + """Test DHCP discovery of unknown device aborts.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.100", + macaddress="aabbccddeeff", + hostname="unknown-gateway", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "no_dhcp_flow" diff --git a/tests/components/sunricher_dali/test_init.py b/tests/components/sunricher_dali/test_init.py index e1ea225f89a..1941b6313d8 100644 --- a/tests/components/sunricher_dali/test_init.py +++ b/tests/components/sunricher_dali/test_init.py @@ -3,9 +3,11 @@ from unittest.mock import MagicMock from PySrDaliGateway.exceptions import DaliGatewayError +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr from tests.common import MockConfigEntry @@ -26,6 +28,25 @@ async def test_setup_entry_success( mock_gateway.connect.assert_called_once() +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that devices are registered correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot + + async def test_setup_entry_connection_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/sunricher_dali/test_light.py b/tests/components/sunricher_dali/test_light.py index 0620ffceb2b..5ccf334b8f7 100644 --- a/tests/components/sunricher_dali/test_light.py +++ b/tests/components/sunricher_dali/test_light.py @@ -1,7 +1,7 @@ """Test the Sunricher DALI light platform.""" from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from PySrDaliGateway import CallbackEventType import pytest @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import find_device_listener +from . import find_device_listener, trigger_availability_callback from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform @@ -35,38 +35,12 @@ def _trigger_light_status_callback( callback(status) -def _trigger_availability_callback( - device: MagicMock, device_id: str, available: bool -) -> None: - """Trigger the availability callbacks registered on the device mock.""" - callback = find_device_listener(device, CallbackEventType.ONLINE_STATUS) - callback(available) - - @pytest.fixture def platforms() -> list[Platform]: """Fixture to specify which platforms to test.""" return [Platform.LIGHT] -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_gateway: MagicMock, - mock_devices: list[MagicMock], - platforms: list[Platform], -) -> MockConfigEntry: - """Set up the integration for testing.""" - mock_config_entry.add_to_hass(hass) - - with patch("homeassistant.components.sunricher_dali._PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - return mock_config_entry - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_entities( hass: HomeAssistant, @@ -192,13 +166,13 @@ async def test_device_availability( init_integration: MockConfigEntry, mock_devices: list[MagicMock], ) -> None: - """Test device availability changes.""" - _trigger_availability_callback(mock_devices[0], TEST_DIMMER_DEVICE_ID, False) + """Test availability changes are reflected in entity state.""" + trigger_availability_callback(mock_devices[0], False) await hass.async_block_till_done() assert (state := hass.states.get(TEST_DIMMER_ENTITY_ID)) assert state.state == "unavailable" - _trigger_availability_callback(mock_devices[0], TEST_DIMMER_DEVICE_ID, True) + trigger_availability_callback(mock_devices[0], True) await hass.async_block_till_done() assert (state := hass.states.get(TEST_DIMMER_ENTITY_ID)) assert state.state != "unavailable" diff --git a/tests/components/sunricher_dali/test_scene.py b/tests/components/sunricher_dali/test_scene.py new file mode 100644 index 00000000000..39fc2e9037b --- /dev/null +++ b/tests/components/sunricher_dali/test_scene.py @@ -0,0 +1,101 @@ +"""Test the Sunricher DALI scene platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import trigger_availability_callback + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +TEST_SCENE_1_ENTITY_ID = "scene.test_gateway_living_room_evening" +TEST_SCENE_2_ENTITY_ID = "scene.test_gateway_kitchen_bright" +TEST_DIMMER_ENTITY_ID = "light.dimmer_0000_02" +TEST_CCT_ENTITY_ID = "light.cct_0000_03" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify which platforms to test.""" + return [Platform.SCENE] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_scenes: list[MagicMock], +) -> None: + """Test the scene entities and their attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entry = device_registry.async_get_device( + identifiers={("sunricher_dali", "6A242121110E")} + ) + assert device_entry + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state is not None + + +async def test_activate_scenes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_scenes: list[MagicMock], +) -> None: + """Test activating single and multiple scenes.""" + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_SCENE_1_ENTITY_ID}, + blocking=True, + ) + mock_scenes[0].activate.assert_called_once() + + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [TEST_SCENE_1_ENTITY_ID, TEST_SCENE_2_ENTITY_ID]}, + blocking=True, + ) + assert mock_scenes[0].activate.call_count == 2 + mock_scenes[1].activate.assert_called_once() + + +async def test_scene_availability( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_scenes: list[MagicMock], +) -> None: + """Test scene availability changes when gateway goes offline.""" + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state is not None + assert state.state != "unavailable" + + # Simulate gateway going offline + trigger_availability_callback(mock_scenes[0], False) + await hass.async_block_till_done() + + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state.state == "unavailable" + + # Simulate gateway coming back online + trigger_availability_callback(mock_scenes[0], True) + await hass.async_block_till_done() + + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state.state != "unavailable" diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py index 7c17b0d4c30..fb22e7c64f1 100644 --- a/tests/components/swiss_public_transport/test_config_flow.py +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -129,7 +129,7 @@ async def test_flow_user_init_data_success( ) if time_mode_input: - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM if CONF_TIME_FIXED in time_mode_input: assert result["step_id"] == "time_fixed" if CONF_TIME_OFFSET in time_mode_input: @@ -139,7 +139,7 @@ async def test_flow_user_init_data_success( user_input=time_mode_input, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == config_title assert result["data"] == {**user_input, **(time_mode_input or {})} @@ -182,7 +182,7 @@ async def test_flow_user_init_data_error_and_recover_on_step_1( user_input=MOCK_USER_DATA_STEP, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == "test_start test_destination" assert result["data"] == MOCK_USER_DATA_STEP @@ -222,7 +222,7 @@ async def test_flow_user_init_data_error_and_recover_on_step_2( result["flow_id"], user_input=MOCK_USER_DATA_STEP_TIME_FIXED, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "time_fixed" with patch( @@ -246,7 +246,7 @@ async def test_flow_user_init_data_error_and_recover_on_step_2( user_input=user_input, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == "test_start test_destination at 18:03:00" diff --git a/tests/components/switch/test_trigger.py b/tests/components/switch/test_trigger.py new file mode 100644 index 00000000000..08b732734b4 --- /dev/null +++ b/tests/components/switch/test_trigger.py @@ -0,0 +1,228 @@ +"""Test switch triggers.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.switch import DOMAIN +from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_switches(hass: HomeAssistant) -> list[str]: + """Create multiple switch entities associated with different targets.""" + return (await target_entities(hass, DOMAIN))["included"] + + +@pytest.mark.parametrize( + "trigger_key", + [ + "switch.turned_off", + "switch.turned_on", + ], +) +async def test_switch_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the switch triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="switch.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + *parametrize_trigger_states( + trigger="switch.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_switch_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_switches: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the switch state trigger fires when any switch state changes to a specific state.""" + other_entity_ids = set(target_switches) - {entity_id} + + # Set all switches, including the tested one, to the initial state + for eid in target_switches: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check if changing other switches also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="switch.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + *parametrize_trigger_states( + trigger="switch.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_switch_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_switches: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the switch state trigger fires when the first switch changes to a specific state.""" + other_entity_ids = set(target_switches) - {entity_id} + + # Set all switches, including the tested one, to the initial state + for eid in target_switches: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other switches should not cause the trigger to fire again + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="switch.turned_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), + *parametrize_trigger_states( + trigger="switch.turned_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_switch_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_switches: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the switch state trigger fires when the last switch changes to a specific state.""" + other_entity_ids = set(target_switches) - {entity_id} + + # Set all switches, including the tested one, to the initial state + for eid in target_switches: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 7ad08d5a7a7..dd3a54e6304 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -44,25 +44,13 @@ from tests.common import MockConfigEntry DOMAIN = "switchbot" -@pytest.fixture -def mock_scanners_all_active() -> Generator[None]: - """Mock all scanners as active mode.""" - mock_scanner = Mock() - mock_scanner.current_mode = BluetoothScanningMode.ACTIVE - with patch( - "homeassistant.components.switchbot.config_flow.async_current_scanners", - return_value=[mock_scanner], - ): - yield - - @pytest.fixture def mock_scanners_all_passive() -> Generator[None]: """Mock all scanners as passive mode.""" mock_scanner = Mock() mock_scanner.current_mode = BluetoothScanningMode.PASSIVE with patch( - "homeassistant.components.switchbot.config_flow.async_current_scanners", + "homeassistant.components.bluetooth.async_current_scanners", return_value=[mock_scanner], ): yield @@ -1461,38 +1449,6 @@ async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} -@pytest.mark.usefixtures("mock_scanners_all_active") -async def test_user_skip_menu_when_all_scanners_active(hass: HomeAssistant) -> None: - """Test that menu is skipped when all scanners are in active mode.""" - with ( - patch( - "homeassistant.components.switchbot.config_flow.async_discovered_service_info", - return_value=[WOHAND_SERVICE_INFO], - ), - patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - # Should skip menu and go directly to select_device -> confirm - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Bot EEFF" - assert result["data"] == { - CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", - CONF_SENSOR_TYPE: "bot", - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_user_show_menu_when_passive_scanner_present(hass: HomeAssistant) -> None: """Test that menu is shown when any scanner is in passive mode.""" mock_scanner_active = Mock() @@ -1502,7 +1458,7 @@ async def test_user_show_menu_when_passive_scanner_present(hass: HomeAssistant) with ( patch( - "homeassistant.components.switchbot.config_flow.async_current_scanners", + "homeassistant.components.bluetooth.async_current_scanners", return_value=[mock_scanner_active, mock_scanner_passive], ), patch( @@ -1546,7 +1502,7 @@ async def test_user_show_menu_when_no_scanners(hass: HomeAssistant) -> None: """Test that menu is shown when no scanners are available.""" with ( patch( - "homeassistant.components.switchbot.config_flow.async_current_scanners", + "homeassistant.components.bluetooth.async_current_scanners", return_value=[], ), patch( diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py index 89bd1b652ba..129fd314f84 100644 --- a/tests/components/system_bridge/__init__.py +++ b/tests/components/system_bridge/__init__.py @@ -4,16 +4,16 @@ from collections.abc import Awaitable, Callable from ipaddress import ip_address from typing import Any -from systembridgemodels.fixtures.modules.battery import FIXTURE_BATTERY -from systembridgemodels.fixtures.modules.cpu import FIXTURE_CPU -from systembridgemodels.fixtures.modules.disks import FIXTURE_DISKS -from systembridgemodels.fixtures.modules.displays import FIXTURE_DISPLAYS -from systembridgemodels.fixtures.modules.gpus import FIXTURE_GPUS -from systembridgemodels.fixtures.modules.media import FIXTURE_MEDIA -from systembridgemodels.fixtures.modules.memory import FIXTURE_MEMORY -from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES -from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM -from systembridgemodels.modules import Module, ModulesData +from systembridgeconnector.models.fixtures.modules.battery import FIXTURE_BATTERY +from systembridgeconnector.models.fixtures.modules.cpu import FIXTURE_CPU +from systembridgeconnector.models.fixtures.modules.disks import FIXTURE_DISKS +from systembridgeconnector.models.fixtures.modules.displays import FIXTURE_DISPLAYS +from systembridgeconnector.models.fixtures.modules.gpus import FIXTURE_GPUS +from systembridgeconnector.models.fixtures.modules.media import FIXTURE_MEDIA +from systembridgeconnector.models.fixtures.modules.memory import FIXTURE_MEMORY +from systembridgeconnector.models.fixtures.modules.processes import FIXTURE_PROCESSES +from systembridgeconnector.models.fixtures.modules.system import FIXTURE_SYSTEM +from systembridgeconnector.models.modules import Module, ModulesData from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant diff --git a/tests/components/system_bridge/conftest.py b/tests/components/system_bridge/conftest.py index 2f1f87485e7..67bafe67bad 100644 --- a/tests/components/system_bridge/conftest.py +++ b/tests/components/system_bridge/conftest.py @@ -8,21 +8,25 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from systembridgeconnector.const import EventKey, EventType -from systembridgemodels.fixtures.modules.battery import FIXTURE_BATTERY -from systembridgemodels.fixtures.modules.cpu import FIXTURE_CPU -from systembridgemodels.fixtures.modules.disks import FIXTURE_DISKS -from systembridgemodels.fixtures.modules.displays import FIXTURE_DISPLAYS -from systembridgemodels.fixtures.modules.gpus import FIXTURE_GPUS -from systembridgemodels.fixtures.modules.media import FIXTURE_MEDIA -from systembridgemodels.fixtures.modules.memory import FIXTURE_MEMORY -from systembridgemodels.fixtures.modules.networks import FIXTURE_NETWORKS -from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES -from systembridgemodels.fixtures.modules.sensors import FIXTURE_SENSORS -from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM -from systembridgemodels.media_directories import MediaDirectory -from systembridgemodels.media_files import MediaFile, MediaFiles -from systembridgemodels.modules import Module, ModulesData, RegisterDataListener -from systembridgemodels.response import Response +from systembridgeconnector.models.fixtures.modules.battery import FIXTURE_BATTERY +from systembridgeconnector.models.fixtures.modules.cpu import FIXTURE_CPU +from systembridgeconnector.models.fixtures.modules.disks import FIXTURE_DISKS +from systembridgeconnector.models.fixtures.modules.displays import FIXTURE_DISPLAYS +from systembridgeconnector.models.fixtures.modules.gpus import FIXTURE_GPUS +from systembridgeconnector.models.fixtures.modules.media import FIXTURE_MEDIA +from systembridgeconnector.models.fixtures.modules.memory import FIXTURE_MEMORY +from systembridgeconnector.models.fixtures.modules.networks import FIXTURE_NETWORKS +from systembridgeconnector.models.fixtures.modules.processes import FIXTURE_PROCESSES +from systembridgeconnector.models.fixtures.modules.sensors import FIXTURE_SENSORS +from systembridgeconnector.models.fixtures.modules.system import FIXTURE_SYSTEM +from systembridgeconnector.models.media_directories import MediaDirectory +from systembridgeconnector.models.media_files import MediaFile, MediaFiles +from systembridgeconnector.models.modules import ( + Module, + ModulesData, + RegisterDataListener, +) +from systembridgeconnector.models.response import Response from homeassistant.components.system_bridge.config_flow import SystemBridgeConfigFlow from homeassistant.components.system_bridge.const import DOMAIN @@ -130,6 +134,7 @@ def mock_websocket_client( websocket_client.get_directories.return_value = [ MediaDirectory( key="documents", + name="Documents", path="/home/user/documents", ) ] @@ -143,6 +148,8 @@ def mock_websocket_client( last_accessed=1630000000, created=1630000000, modified=1630000000, + mod_time=1630000000, + permissions="rwxr-xr-x", is_directory=True, is_file=False, is_link=False, @@ -155,6 +162,8 @@ def mock_websocket_client( last_accessed=1630000000, created=1630000000, modified=1630000000, + mod_time=1630000000, + permissions="rw-r--r--", is_directory=False, is_file=True, is_link=False, @@ -168,6 +177,8 @@ def mock_websocket_client( last_accessed=1630000000, created=1630000000, modified=1630000000, + mod_time=1630000000, + permissions="rw-r--r--", is_directory=False, is_file=True, is_link=False, diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 825e01aca70..3fcbfa1e5ef 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -34,7 +34,7 @@ async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: DOMAIN, context={"source": SOURCE_USER} ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,7 +44,7 @@ async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: }, ) - assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["data"] == { CONF_HOST: "192.168.1.62", CONF_LOCAL_ACCESS_TOKEN: "token", diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index b36606f2e77..7cb8e48bed5 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -17,15 +17,13 @@ from telegram import ( ) from telegram.constants import ChatType -from homeassistant.components.telegram_bot import ( +from homeassistant.components.telegram_bot.const import ( ATTR_PARSER, CONF_ALLOWED_CHAT_IDS, + CONF_CHAT_ID, CONF_TRUSTED_NETWORKS, DOMAIN, PARSER_MD, -) -from homeassistant.components.telegram_bot.const import ( - CONF_CHAT_ID, PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, @@ -33,32 +31,10 @@ from homeassistant.components.telegram_bot.const import ( from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -@pytest.fixture -def config_webhooks() -> dict[str, Any]: - """Fixture for a webhooks platform configuration.""" - return { - DOMAIN: [ - { - CONF_PLATFORM: PLATFORM_WEBHOOKS, - CONF_URL: "https://test", - CONF_TRUSTED_NETWORKS: ["127.0.0.1"], - CONF_API_KEY: "1234567890:ABC", - CONF_ALLOWED_CHAT_IDS: [ - # "me" - 12345678, - # Some chat - -123456789, - ], - } - ] - } - - @pytest.fixture def mock_polling_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -337,25 +313,6 @@ async def webhook_bot( await hass.async_stop() -@pytest.fixture -async def webhook_platform( - hass: HomeAssistant, - config_webhooks: dict[str, Any], - mock_register_webhook: None, - mock_external_calls: None, - mock_generate_secret_token: str, -) -> AsyncGenerator[None]: - """Fixture for setting up the webhooks platform using appropriate config and mocks.""" - await async_setup_component( - hass, - DOMAIN, - config_webhooks, - ) - await hass.async_block_till_done() - yield - await hass.async_stop() - - @pytest.fixture def mock_polling_calls() -> Generator[None]: """Fixture for setting up the polling platform using appropriate config and mocks.""" diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 051fd03ca12..a88a27885af 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -8,17 +8,10 @@ from telegram.error import BadRequest, InvalidToken, NetworkError from homeassistant.components.telegram_bot.const import ( ATTR_PARSER, - BOT_NAME, - CONF_ALLOWED_CHAT_IDS, - CONF_BOT_COUNT, CONF_CHAT_ID, CONF_PROXY_URL, CONF_TRUSTED_NETWORKS, DOMAIN, - ERROR_FIELD, - ERROR_MESSAGE, - ISSUE_DEPRECATED_YAML, - ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR, PARSER_MD, PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, @@ -26,11 +19,10 @@ from homeassistant.components.telegram_bot.const import ( SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigSubentry +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.issue_registry import IssueRegistry from tests.common import MockConfigEntry @@ -50,7 +42,7 @@ async def test_options_flow( await hass.async_block_till_done() assert result["step_id"] == "init" - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM # test: valid input @@ -62,7 +54,7 @@ async def test_options_flow( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][ATTR_PARSER] == PARSER_PLAIN_TEXT @@ -472,109 +464,6 @@ async def test_subentry_flow_chat_error( assert result["reason"] == "already_configured" -async def test_import_failed( - hass: HomeAssistant, issue_registry: IssueRegistry -) -> None: - """Test import flow failed.""" - - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me" - ) as mock_bot: - mock_bot.side_effect = InvalidToken("mock invalid token error") - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_API_KEY: "mock api key", - CONF_TRUSTED_NETWORKS: ["149.154.160.0/20"], - CONF_BOT_COUNT: 1, - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "import_failed" - - issue = issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=ISSUE_DEPRECATED_YAML, - ) - assert issue.translation_key == ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR - assert ( - issue.translation_placeholders[BOT_NAME] == f"{PLATFORM_BROADCAST} Telegram bot" - ) - assert issue.translation_placeholders[ERROR_FIELD] == "API key" - assert issue.translation_placeholders[ERROR_MESSAGE] == "mock invalid token error" - - -async def test_import_multiple( - hass: HomeAssistant, issue_registry: IssueRegistry -) -> None: - """Test import flow with multiple duplicated entries.""" - - data = { - CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_API_KEY: "mock api key", - CONF_TRUSTED_NETWORKS: ["149.154.160.0/20"], - CONF_ALLOWED_CHAT_IDS: [3334445550], - CONF_BOT_COUNT: 2, - } - - with ( - patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), - ), - patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_chat", - return_value=ChatFullInfo( - id=987654321, - title="mock title", - first_name="mock first_name", - type="PRIVATE", - max_reaction_count=100, - accent_color_id=AccentColor.COLOR_000, - accepted_gift_types=AcceptedGiftTypes(True, True, True, True), - ), - ), - ): - # test: import first entry success - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_PLATFORM] == PLATFORM_BROADCAST - assert result["data"][CONF_API_KEY] == "mock api key" - assert result["options"][ATTR_PARSER] == PARSER_MD - - issue = issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=ISSUE_DEPRECATED_YAML, - ) - assert ( - issue.translation_key == "deprecated_yaml_import_issue_has_more_platforms" - ) - - # test: import 2nd entry failed due to duplicate - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_duplicate_entry(hass: HomeAssistant) -> None: """Test user flow with duplicated entries.""" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 9524e0a739b..1118dea6c59 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -2,6 +2,7 @@ import base64 from datetime import datetime +from http import HTTPStatus import io from typing import Any from unittest.mock import AsyncMock, MagicMock, mock_open, patch @@ -46,7 +47,6 @@ from homeassistant.components.telegram_bot.const import ( ATTR_SHOW_ALERT, ATTR_STICKER_ID, ATTR_TARGET, - ATTR_TIMEOUT, ATTR_URL, ATTR_USERNAME, ATTR_VERIFY_SSL, @@ -76,18 +76,22 @@ from homeassistant.components.telegram_bot.const import ( ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.const import ( + ATTR_DOMAIN, + ATTR_ENTITY_ID, + ATTR_SERVICE, CONF_API_KEY, CONF_PLATFORM, HTTP_BASIC_AUTHENTICATION, HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, Event, HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, HomeAssistantError, ServiceValidationError, ) +from homeassistant.helpers.issue_registry import IssueRegistry from homeassistant.util import json as json_util from homeassistant.util.file import write_utf8_file @@ -95,7 +99,7 @@ from tests.common import MockConfigEntry, async_capture_events, async_load_fixtu from tests.typing import ClientSessionGenerator -async def test_webhook_platform_init(hass: HomeAssistant, webhook_platform) -> None: +async def test_webhook_platform_init(hass: HomeAssistant, webhook_bot) -> None: """Test initialization of the webhooks platform.""" assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True @@ -124,7 +128,6 @@ async def test_polling_platform_init( ATTR_KEYBOARD: ["/command1, /command2", "/command3"], ATTR_MESSAGE: "test_message", ATTR_PARSER: ParseMode.HTML, - ATTR_TIMEOUT: 15, ATTR_DISABLE_NOTIF: True, ATTR_DISABLE_WEB_PREV: True, ATTR_MESSAGE_TAG: "mock_tag", @@ -155,7 +158,6 @@ async def test_polling_platform_init( ( SERVICE_SEND_LOCATION, { - ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123", ATTR_LONGITUDE: "1.123", ATTR_LATITUDE: "1.123", @@ -411,6 +413,7 @@ async def test_send_chat_action( CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, ATTR_TARGET: [123456], ATTR_CHAT_ACTION: CHAT_ACTION_TYPING, + ATTR_MESSAGE_THREAD_ID: 123, }, blocking=True, return_response=True, @@ -418,7 +421,9 @@ async def test_send_chat_action( await hass.async_block_till_done() mock.assert_called_once() - mock.assert_called_with(chat_id=123456, action=CHAT_ACTION_TYPING) + mock.assert_called_with( + chat_id=123456, action=CHAT_ACTION_TYPING, message_thread_id=123 + ) @pytest.mark.parametrize( @@ -1068,7 +1073,6 @@ async def test_edit_message_media( ATTR_MEDIA_TYPE: media_type, ATTR_MESSAGEID: 12345, ATTR_CHAT_ID: 123456, - ATTR_TIMEOUT: 10, ATTR_KEYBOARD_INLINE: "/mock", }, blocking=True, @@ -1083,7 +1087,6 @@ async def test_edit_message_media( assert mock.call_args[1]["reply_markup"] == InlineKeyboardMarkup( [[InlineKeyboardButton(callback_data="/mock", text="MOCK")]] ) - assert mock.call_args[1]["read_timeout"] == 10 async def test_edit_message( @@ -1504,7 +1507,6 @@ async def test_set_message_reaction( SERVICE_SEND_LOCATION, { ATTR_TARGET: 654321, - ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123", ATTR_LONGITUDE: "1.123", ATTR_LATITUDE: "1.123", @@ -1544,3 +1546,106 @@ async def test_send_message_multi_target( await hass.async_block_till_done() assert response == {"chats": [{"chat_id": 654321, "message_id": 12345}]} + + +@pytest.mark.parametrize( + ("event", "expected_action_origin"), + [ + ( + Event("automation_triggered", {ATTR_ENTITY_ID: "automation.automation_0"}), + "automation.automation_0", + ), + ( + Event("call_service", {ATTR_DOMAIN: "script", ATTR_SERVICE: "mock_script"}), + "script.mock_script", + ), + ( + None, + "call_service", + ), + ], +) +async def test_deprecated_timeout_parameter( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + issue_registry: IssueRegistry, + event: Event | None, + expected_action_origin: str, +) -> None: + """Test send message using the deprecated timeout parameter.""" + + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + # trigger service call + context = Context() + context.origin_event = event + await hass.services.async_call( + DOMAIN, + "send_message", + {"message": "test message", "timeout": 5}, + blocking=True, + context=context, + ) + + # check issue is created correctly + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_timeout_parameter", + ) + assert issue is not None + assert issue.domain == DOMAIN + assert issue.translation_key == "deprecated_timeout_parameter" + assert issue.translation_placeholders == { + "integration_title": "Telegram Bot", + "action": "telegram_bot.send_message", + "action_origin": expected_action_origin, + } + + # fix the issue via repair flow + + client = await hass_client() + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": DOMAIN, + "step_id": "confirm", + "data_schema": [], + "errors": None, + "description_placeholders": { + "integration_title": "Telegram Bot", + "action": "telegram_bot.send_message", + "action_origin": expected_action_origin, + }, + "last_step": None, + "preview": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": DOMAIN, + "description": None, + "description_placeholders": None, + } + + # verify issue is resolved + assert not issue_registry.async_get_issue(DOMAIN, "deprecated_timeout_parameter") diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index 138d44d5b54..ef59df3fb8f 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -42,7 +42,7 @@ async def test_set_webhooks_failed( assert mock_set_webhook.call_count == 2 # SETUP_ERROR is result of ConfigEntryNotReady("Failed to register webhook with Telegram") in webhooks.py - assert mock_webhooks_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_webhooks_config_entry.state is ConfigEntryState.SETUP_ERROR # test fail after retries @@ -55,7 +55,7 @@ async def test_set_webhooks_failed( # 3 retries assert mock_set_webhook.call_count == 3 - assert mock_webhooks_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_webhooks_config_entry.state is ConfigEntryState.SETUP_ERROR await hass.async_block_till_done() @@ -72,7 +72,7 @@ async def test_set_webhooks( await hass.async_block_till_done() - assert mock_webhooks_config_entry.state == ConfigEntryState.LOADED + assert mock_webhooks_config_entry.state is ConfigEntryState.LOADED async def test_webhooks_update_invalid_json( @@ -125,7 +125,7 @@ async def test_webhooks_deregister_failed( """Test deregister webhooks.""" config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED with patch( "homeassistant.components.telegram_bot.webhooks.Bot.delete_webhook", @@ -134,4 +134,4 @@ async def test_webhooks_deregister_failed( await hass.config_entries.async_unload(config_entry.entry_id) mock_delete_webhook.assert_called_once() - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index c57d1dcbfab..40b10789bd4 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,5 +1,6 @@ """template conftest.""" +from dataclasses import dataclass from enum import Enum import pytest @@ -8,6 +9,7 @@ from homeassistant.components import template from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -40,19 +42,37 @@ def make_test_trigger(*entities: str) -> dict: } +async def async_trigger( + hass: HomeAssistant, entity_id: str, state: str | None = None +) -> None: + """Trigger a state change.""" + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + async def async_setup_legacy_platforms( hass: HomeAssistant, domain: str, - slug: str, + slug: str | None, count: int, - config: ConfigType, + config: ConfigType | list[ConfigType], ) -> None: """Do setup of any legacy platform that supports a keyed dictionary of template entities.""" + if slug is None: + # Lock and Weather platforms do not use a slug + if isinstance(config, list): + config = {domain: [{"platform": "template", **item} for item in config]} + else: + config = {domain: {"platform": "template", **config}} + else: + assert isinstance(config, dict) + config = {domain: {"platform": "template", slug: config}} + with assert_setup_component(count, domain): assert await async_setup_component( hass, domain, - {domain: {"platform": "template", slug: config}}, + config, ) await hass.async_block_till_done() @@ -64,16 +84,15 @@ async def async_setup_modern_state_format( hass: HomeAssistant, domain: str, count: int, - config: ConfigType, - extra_config: ConfigType | None = None, + config: ConfigType | list[ConfigType], + extra_section_config: ConfigType | None = None, ) -> None: """Do setup of template integration via modern format.""" - extra = extra_config or {} with assert_setup_component(count, template.DOMAIN): assert await async_setup_component( hass, template.DOMAIN, - {"template": {domain: config, **extra}}, + {"template": {domain: config, **(extra_section_config or {})}}, ) await hass.async_block_till_done() @@ -86,12 +105,11 @@ async def async_setup_modern_trigger_format( domain: str, trigger: dict, count: int, - config: ConfigType, - extra_config: ConfigType | None = None, + config: ConfigType | list[ConfigType], + extra_section_config: ConfigType | None = None, ) -> None: """Do setup of template integration via trigger format.""" - extra = extra_config or {} - config = {"template": {domain: config, **trigger, **extra}} + config = {"template": {domain: config, **trigger, **(extra_section_config or {})}} with assert_setup_component(count, template.DOMAIN): assert await async_setup_component( @@ -105,6 +123,164 @@ async def async_setup_modern_trigger_format( await hass.async_block_till_done() +@dataclass(frozen=True) +class TemplatePlatformSetup: + """Template Platform Setup Information.""" + + domain: str + legacy_slug: str | None + object_id: str + trigger: ConfigType + + @property + def entity_id(self) -> str: + """Return test entity ID.""" + return f"{self.domain}.{self.object_id}" + + +async def setup_entity( + hass: HomeAssistant, + platform_setup: TemplatePlatformSetup, + style: ConfigurationStyle, + count: int, + config: ConfigType, + state_template: str | None = None, + extra_config: ConfigType | None = None, + attributes: ConfigType | None = None, + extra_section_config: ConfigType | None = None, +) -> None: + """Do setup of a template entity based on the configuration style.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_platforms( + hass, + platform_setup.domain, + platform_setup.legacy_slug, + count, + { + platform_setup.object_id: { + **({"value_template": state_template} if state_template else {}), + **config, + **(extra_config or {}), + **({"attribute_templates": attributes} if attributes else {}), + } + }, + ) + return + + entity_config = { + "name": platform_setup.object_id, + **({"state": state_template} if state_template else {}), + **config, + **({"attributes": attributes} if attributes else {}), + **(extra_config or {}), + } + if style == ConfigurationStyle.MODERN: + await async_setup_modern_state_format( + hass, platform_setup.domain, count, entity_config, extra_section_config + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_modern_trigger_format( + hass, + platform_setup.domain, + platform_setup.trigger, + count, + entity_config, + extra_section_config, + ) + + +async def setup_and_test_unique_id( + hass: HomeAssistant, + platform_setup: TemplatePlatformSetup, + style: ConfigurationStyle, + entity_config: ConfigType | None, +) -> None: + """Setup 2 entities with the same unique_id and verify only 1 entity is created. + + The entity_config not provide name or unique_id, those are added automatically. + """ + entity_config = {"unique_id": "not-so_-unique-anymore", **(entity_config or {})} + if style == ConfigurationStyle.LEGACY: + if platform_setup.legacy_slug is None: + config = [ + {"name": "template_entity_1", **entity_config}, + {"name": "template_entity_2", **entity_config}, + ] + else: + config = { + "template_entity_1": entity_config, + "template_entity_2": entity_config, + } + await async_setup_legacy_platforms( + hass, platform_setup.domain, platform_setup.legacy_slug, 1, config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_state_format( + hass, + platform_setup.domain, + 1, + [ + {"name": "template_entity_1", **entity_config}, + {"name": "template_entity_2", **entity_config}, + ], + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_modern_trigger_format( + hass, + platform_setup.domain, + platform_setup.trigger, + 1, + [ + {"name": "template_entity_1", **entity_config}, + {"name": "template_entity_2", **entity_config}, + ], + ) + + assert len(hass.states.async_all(platform_setup.domain)) == 1 + + +async def setup_and_test_nested_unique_id( + hass: HomeAssistant, + platform_setup: TemplatePlatformSetup, + style: ConfigurationStyle, + entity_registry: er.EntityRegistry, + entity_config: ConfigType | None, +) -> None: + """Setup 2 entities with unique unique_ids in a template section that contains a unique_id. + + The test will verify that 2 entities are created where the unique_id appends the + section unique_id to each entity unique_id. + + The entity_config should not provide name or unique_id, those are added automatically. + """ + entities = [ + {"name": "test_a", "unique_id": "a", **(entity_config or {})}, + {"name": "test_b", "unique_id": "b", **(entity_config or {})}, + ] + extra_section_config = {"unique_id": "x"} + if style == ConfigurationStyle.MODERN: + await async_setup_modern_state_format( + hass, platform_setup.domain, 1, entities, extra_section_config + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_modern_trigger_format( + hass, + platform_setup.domain, + platform_setup.trigger, + 1, + entities, + extra_section_config, + ) + + assert len(hass.states.async_all(platform_setup.domain)) == 2 + + entry = entity_registry.async_get(f"{platform_setup.domain}.test_a") + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get(f"{platform_setup.domain}.test_b") + assert entry.unique_id == "x-b" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 5a884160fe8..2a15d979098 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -18,9 +18,19 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle, async_get_flow_preview_state +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + async_get_flow_preview_state, + async_trigger, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) from tests.common import ( MockConfigEntry, @@ -31,19 +41,15 @@ from tests.common import ( from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_switch" -TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "switch.test_state" +TEST_SENSOR = "sensor.test_sensor" -TEST_EVENT_TRIGGER = { - "triggers": [ - {"trigger": "event", "event_type": "test_event"}, - {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID]}, - ], - "variables": { - "type": "{{ trigger.event.data.type if trigger.event is defined else trigger.entity_id }}" - }, - "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], -} +TEST_SWITCH = TemplatePlatformSetup( + switch.DOMAIN, + "switches", + "test_template_switch", + make_test_trigger(TEST_STATE_ENTITY_ID, TEST_SENSOR), +) SWITCH_TURN_ON = { "service": "test.automation", @@ -63,76 +69,6 @@ SWITCH_ACTIONS = { "turn_on": SWITCH_TURN_ON, "turn_off": SWITCH_TURN_OFF, } -NAMED_SWITCH_ACTIONS = { - **SWITCH_ACTIONS, - "name": TEST_OBJECT_ID, -} -UNIQUE_ID_CONFIG = { - **SWITCH_ACTIONS, - "unique_id": "not-so-unique-anymore", -} - - -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, switch_config: dict[str, Any] -) -> None: - """Do setup of switch integration via legacy format.""" - config = {"switch": {"platform": "template", "switches": switch_config}} - - with assert_setup_component(count, switch.DOMAIN): - assert await async_setup_component( - hass, - switch.DOMAIN, - config, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_modern_format( - hass: HomeAssistant, count: int, switch_config: dict[str, Any] -) -> None: - """Do setup of switch integration via modern format.""" - config = {"template": {"switch": switch_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, count: int, switch_config: dict[str, Any] -) -> None: - """Do setup of switch integration via modern format.""" - config = {"template": {**TEST_EVENT_TRIGGER, "switch": switch_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_ensure_triggered_entity_updates( - hass: HomeAssistant, style: ConfigurationStyle, **kwargs -) -> None: - """Trigger template entities.""" - if style == ConfigurationStyle.TRIGGER: - hass.bus.async_fire("test_event", {"type": "test_event", **kwargs}) - await hass.async_block_till_done() @pytest.fixture @@ -143,12 +79,7 @@ async def setup_switch( switch_config: dict[str, Any], ) -> None: """Do setup of switch integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, switch_config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, switch_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, switch_config) + await setup_entity(hass, TEST_SWITCH, style, count, switch_config) @pytest.fixture @@ -159,35 +90,23 @@ async def setup_state_switch( state_template: str, ): """Do setup of switch integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **SWITCH_ACTIONS, - "value_template": state_template, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - "state": state_template, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - "state": state_template, - }, - ) + await setup_entity( + hass, TEST_SWITCH, style, count, SWITCH_ACTIONS, state_template=state_template + ) + + +@pytest.fixture +async def setup_state_switch_with_extra( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + config: ConfigType, +): + """Do setup of switch integration using a state template.""" + await setup_entity( + hass, TEST_SWITCH, style, count, config, state_template=state_template + ) @pytest.fixture @@ -199,39 +118,17 @@ async def setup_single_attribute_switch( attribute_template: str, ) -> None: """Do setup of switch integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **SWITCH_ACTIONS, - "value_template": "{{ 1 == 1 }}", - **extra, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - "state": "{{ 1 == 1 }}", - **extra, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - "state": "{{ 1 == 1 }}", - **extra, - }, - ) + await setup_entity( + hass, + TEST_SWITCH, + style, + count, + SWITCH_ACTIONS, + state_template="{{ 1 == 1 }}", + extra_config=( + {attribute: attribute_template} if attribute and attribute_template else {} + ), + ) @pytest.fixture @@ -241,32 +138,7 @@ async def setup_optimistic_switch( style: ConfigurationStyle, ) -> None: """Do setup of an optimistic switch.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **SWITCH_ACTIONS, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - }, - ) + await setup_entity(hass, TEST_SWITCH, style, count, SWITCH_ACTIONS) @pytest.fixture @@ -278,36 +150,16 @@ async def setup_single_attribute_optimistic_switch( attribute_template: str, ) -> None: """Do setup of switch integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **SWITCH_ACTIONS, - **extra, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - **extra, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_SWITCH_ACTIONS, - **extra, - }, - ) + await setup_entity( + hass, + TEST_SWITCH, + style, + count, + SWITCH_ACTIONS, + extra_config=( + {attribute: attribute_template} if attribute and attribute_template else {} + ), + ) @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) @@ -315,14 +167,13 @@ async def setup_single_attribute_optimistic_switch( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_setup( - hass: HomeAssistant, style: ConfigurationStyle, setup_state_switch -) -> None: +@pytest.mark.usefixtures("setup_state_switch") +async def test_setup(hass: HomeAssistant) -> None: """Test template.""" - await async_ensure_triggered_entity_updates(hass, style) - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state is not None - assert state.name == TEST_OBJECT_ID + assert state.name == TEST_SWITCH.object_id assert state.state == STATE_ON @@ -385,24 +236,17 @@ async def test_flow_preview( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_template_state_text( - hass: HomeAssistant, style: ConfigurationStyle, setup_state_switch -) -> None: +@pytest.mark.usefixtures("setup_state_switch") +async def test_template_state_text(hass: HomeAssistant) -> None: """Test the state text of a template.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - await async_ensure_triggered_entity_updates(hass, style) - - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - await async_ensure_triggered_entity_updates(hass, style) - - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF @@ -418,12 +262,11 @@ async def test_template_state_text( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_template_state_boolean( - hass: HomeAssistant, expected: str, style: ConfigurationStyle, setup_state_switch -) -> None: +@pytest.mark.usefixtures("setup_state_switch") +async def test_template_state_boolean(hass: HomeAssistant, expected: str) -> None: """Test the setting of the state with boolean template.""" - await async_ensure_triggered_entity_updates(hass, style) - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == expected @@ -439,102 +282,92 @@ async def test_template_state_boolean( (ConfigurationStyle.TRIGGER, "icon"), ], ) -async def test_icon_template( - hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_switch") +async def test_icon_template(hass: HomeAssistant) -> None: """Test the state text of a template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.attributes.get("icon") in ("", None) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - await async_ensure_triggered_entity_updates(hass, style) - - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.attributes["icon"] == "mdi:check" @pytest.mark.parametrize( - ("config_attr", "attribute", "expected"), + ("count", "attribute_template"), + [(1, "{{ states('sensor.test_sensor') }}")], +) +@pytest.mark.parametrize("style", [ConfigurationStyle.TRIGGER]) +@pytest.mark.parametrize( + ("attribute", "attr", "expected"), [("icon", "icon", "mdi:icon"), ("picture", "entity_picture", "picture.jpg")], ) -async def test_attributes_with_optimistic_state( +@pytest.mark.usefixtures("setup_single_attribute_optimistic_switch") +async def test_trigger_attributes_with_optimistic_state( hass: HomeAssistant, - config_attr: str, - attribute: str, + attr: str, expected: str, calls: list[ServiceCall], ) -> None: """Test attributes when trigger entity is optimistic.""" - await async_setup_trigger_format( - hass, - 1, - { - **NAMED_SWITCH_ACTIONS, - config_attr: "{{ trigger.event.data.attr }}", - }, - ) - - hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) + hass.states.async_set(TEST_SWITCH.entity_id, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF - assert state.attributes.get(attribute) is None + assert state.attributes.get(attr) is None await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON - assert state.attributes.get(attribute) is None + assert state.attributes.get(attr) is None assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF - assert state.attributes.get(attribute) is None + assert state.attributes.get(attr) is None assert len(calls) == 2 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id - await async_ensure_triggered_entity_updates( - hass, ConfigurationStyle.TRIGGER, attr=expected - ) + await async_trigger(hass, TEST_SENSOR, expected) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF - assert state.attributes.get(attribute) == expected + assert state.attributes.get(attr) == expected await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON - assert state.attributes.get(attribute) == expected + assert state.attributes.get(attr) == expected assert len(calls) == 3 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id @pytest.mark.parametrize( @@ -549,19 +382,17 @@ async def test_attributes_with_optimistic_state( (ConfigurationStyle.TRIGGER, "picture"), ], ) +@pytest.mark.usefixtures("setup_single_attribute_switch") async def test_entity_picture_template( - hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch + hass: HomeAssistant, style: ConfigurationStyle ) -> None: """Test entity_picture template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.attributes.get("entity_picture") in ("", None) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - await async_ensure_triggered_entity_updates(hass, style) - - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.attributes["entity_picture"] == "/local/switch.png" @@ -570,7 +401,8 @@ async def test_entity_picture_template( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_template_syntax_error(hass: HomeAssistant, setup_state_switch) -> None: +@pytest.mark.usefixtures("setup_state_switch") +async def test_template_syntax_error(hass: HomeAssistant) -> None: """Test templating syntax error.""" assert hass.states.async_all("switch") == [] @@ -614,7 +446,7 @@ async def test_invalid_legacy_slug_does_not_create(hass: HomeAssistant) -> None: { "switch": { "platform": "template", - "switches": {TEST_OBJECT_ID: "Invalid"}, + "switches": {TEST_SWITCH.object_id: "Invalid"}, } }, switch.DOMAIN, @@ -671,94 +503,28 @@ async def test_no_switches_does_not_create( @pytest.mark.parametrize( - ("config", "domain"), - [ - ( - { - "template": { - "switch": { - "not_on": SWITCH_TURN_ON, - "turn_off": SWITCH_TURN_OFF, - "state": "{{ states.switch.test_state.state }}", - } - }, - }, - template.DOMAIN, - ), - ( - { - "switch": { - "platform": "template", - "switches": { - TEST_OBJECT_ID: { - "not_on": SWITCH_TURN_ON, - "turn_off": SWITCH_TURN_OFF, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - switch.DOMAIN, - ), - ], + ("count", "state_template"), [(0, "{{ states.switch.test_state.state }}")] ) -async def test_missing_on_does_not_create( - hass: HomeAssistant, config: dict, domain: str -) -> None: - """Test missing on.""" - with assert_setup_component(0, domain): - assert await async_setup_component(hass, domain, config) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - @pytest.mark.parametrize( - ("config", "domain"), + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", [ - ( - { - "template": { - "switch": { - "turn_on": SWITCH_TURN_ON, - "not_off": SWITCH_TURN_OFF, - "state": "{{ states.switch.test_state.state }}", - } - }, - }, - template.DOMAIN, - ), - ( - { - "switch": { - "platform": "template", - "switches": { - TEST_OBJECT_ID: { - "turn_on": SWITCH_TURN_ON, - "not_off": SWITCH_TURN_OFF, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - switch.DOMAIN, - ), + { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + }, + { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + }, ], ) -async def test_missing_off_does_not_create( - hass: HomeAssistant, config: dict, domain: str -) -> None: - """Test missing off.""" - with assert_setup_component(0, domain): - assert await async_setup_component(hass, domain, config) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - +@pytest.mark.usefixtures("setup_state_switch_with_extra") +async def test_missing_action_does_not_create(hass: HomeAssistant) -> None: + """Test missing actions.""" assert hass.states.async_all("switch") == [] @@ -769,31 +535,27 @@ async def test_missing_off_does_not_create( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) +@pytest.mark.usefixtures("setup_state_switch") async def test_on_action( hass: HomeAssistant, - style: ConfigurationStyle, - setup_state_switch, calls: list[ServiceCall], ) -> None: """Test on action.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - await async_ensure_triggered_entity_updates(hass, style) - - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id @pytest.mark.parametrize("count", [1]) @@ -801,29 +563,30 @@ async def test_on_action( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) +@pytest.mark.usefixtures("setup_optimistic_switch") async def test_on_action_optimistic( - hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test on action in optimistic mode.""" - hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) + hass.states.async_set(TEST_SWITCH.entity_id, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id @pytest.mark.parametrize( @@ -833,31 +596,24 @@ async def test_on_action_optimistic( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_off_action( - hass: HomeAssistant, - style: ConfigurationStyle, - setup_state_switch, - calls: list[ServiceCall], -) -> None: +@pytest.mark.usefixtures("setup_state_switch") +async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test off action.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - await async_ensure_triggered_entity_updates(hass, style) - - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id @pytest.mark.parametrize("count", [1]) @@ -865,115 +621,54 @@ async def test_off_action( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) +@pytest.mark.usefixtures("setup_optimistic_switch") async def test_off_action_optimistic( - hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test off action in optimistic mode.""" - hass.states.async_set(TEST_ENTITY_ID, STATE_ON) + hass.states.async_set(TEST_SWITCH.entity_id, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_SWITCH.entity_id -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain"), - [ - ( - { - "switch": { - "platform": "template", - "switches": { - "s1": { - **SWITCH_ACTIONS, - }, - "s2": { - **SWITCH_ACTIONS, - }, - }, - } - }, - switch.DOMAIN, - ), - ( - { - "template": { - "switch": [ - { - "name": "s1", - **SWITCH_ACTIONS, - }, - { - "name": "s2", - **SWITCH_ACTIONS, - }, - ], - } - }, - template.DOMAIN, - ), - ( - { - "template": { - "trigger": {"trigger": "event", "event_type": "test_event"}, - "switch": [ - { - "name": "s1", - **SWITCH_ACTIONS, - }, - { - "name": "s2", - **SWITCH_ACTIONS, - }, - ], - } - }, - template.DOMAIN, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) +@pytest.mark.parametrize("test_state", [STATE_ON, STATE_OFF]) async def test_restore_state( - hass: HomeAssistant, count: int, domain: str, config: dict[str, Any] + hass: HomeAssistant, style: ConfigurationStyle, test_state: str ) -> None: """Test state restoration.""" mock_restore_cache( hass, - ( - State("switch.s1", STATE_ON), - State("switch.s2", STATE_OFF), - ), + (State(TEST_SWITCH.entity_id, test_state),), ) hass.set_state(CoreState.starting) mock_component(hass, "recorder") - with assert_setup_component(count, domain): - await async_setup_component(hass, domain, config) + await setup_entity(hass, TEST_SWITCH, style, 1, SWITCH_ACTIONS) - await hass.async_block_till_done() - - state = hass.states.get("switch.s1") + state = hass.states.get(TEST_SWITCH.entity_id) assert state - assert state.state == STATE_ON - - state = hass.states.get("switch.s2") - assert state - assert state.state == STATE_OFF + assert state.state == test_state @pytest.mark.parametrize( @@ -988,150 +683,73 @@ async def test_restore_state( (ConfigurationStyle.TRIGGER, "availability"), ], ) -async def test_available_template_with_entities( - hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_switch") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - await async_ensure_triggered_entity_updates(hass, style) + assert hass.states.get(TEST_SWITCH.entity_id).state != STATE_UNAVAILABLE - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() - - await async_ensure_triggered_entity_updates(hass, style) - - assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_SWITCH.entity_id).state == STATE_UNAVAILABLE -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain"), + ("style", "config"), [ - ( - { - "switch": { - "platform": "template", - "switches": { - TEST_OBJECT_ID: { - **SWITCH_ACTIONS, - "value_template": "{{ true }}", - "availability_template": "{{ x - 12 }}", - } - }, - } - }, - switch.DOMAIN, - ), - ( - { - "template": { - "switch": { - **NAMED_SWITCH_ACTIONS, - "state": "{{ true }}", - "availability": "{{ x - 12 }}", - }, - } - }, - template.DOMAIN, - ), + (ConfigurationStyle.LEGACY, {"availability_template": "{{ x - 12 }}"}), + (ConfigurationStyle.MODERN, {"availability": "{{ x - 12 }}"}), + (ConfigurationStyle.TRIGGER, {"availability": "{{ x - 12 }}"}), ], ) async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, - count: int, + style: ConfigurationStyle, config: dict[str, Any], - domain: str, caplog: pytest.LogCaptureFixture, ) -> None: """Test that an invalid availability keeps the device available.""" - with assert_setup_component(count, domain): - await async_setup_component(hass, domain, config) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + await setup_entity( + hass, + TEST_SWITCH, + style, + 1, + config, + extra_config=SWITCH_ACTIONS, + state_template="{{ true }}", + ) + await async_trigger(hass, TEST_STATE_ENTITY_ID) + assert hass.states.get(TEST_SWITCH.entity_id).state != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog.text -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("config", [SWITCH_ACTIONS]) @pytest.mark.parametrize( - ("switch_config", "style"), - [ - ( - { - "test_template_switch_01": UNIQUE_ID_CONFIG, - "test_template_switch_02": UNIQUE_ID_CONFIG, - }, - ConfigurationStyle.LEGACY, - ), - ( - [ - { - "name": "test_template_switch_01", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_switch_02", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_unique_id(hass: HomeAssistant, setup_switch) -> None: +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: """Test unique_id option only creates one switch per id.""" - assert len(hass.states.async_all("switch")) == 1 + await setup_and_test_unique_id(hass, TEST_SWITCH, style, config) +@pytest.mark.parametrize("config", [SWITCH_ACTIONS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, ) -> None: """Test a template unique_id propagates to switch unique_ids.""" - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "switch": [ - { - **SWITCH_ACTIONS, - "name": "test_a", - "unique_id": "a", - "state": "{{ true }}", - }, - { - **SWITCH_ACTIONS, - "name": "test_b", - "unique_id": "b", - "state": "{{ true }}", - }, - ], - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("switch")) == 2 - - entry = entity_registry.async_get("switch.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("switch.test_b") - assert entry - assert entry.unique_id == "x-b" + await setup_and_test_nested_unique_id( + hass, TEST_SWITCH, style, entity_registry, config + ) async def test_device_id( @@ -1173,49 +791,35 @@ async def test_device_id( assert template_entity.device_id == device_entry.id -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "switch_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - TEST_OBJECT_ID: { - "turn_on": [], - "turn_off": [], - }, - }, - ), - ( - ConfigurationStyle.MODERN, - { - "name": TEST_OBJECT_ID, - "turn_on": [], - "turn_off": [], - }, - ), - ], + ("count", "switch_config"), + [(1, {"turn_on": [], "turn_off": []})], ) -async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_switch") +async def test_empty_action_config(hass: HomeAssistant) -> None: """Test configuration with empty script.""" await hass.services.async_call( switch.DOMAIN, switch.SERVICE_TURN_ON, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON await hass.services.async_call( switch.DOMAIN, switch.SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF @@ -1225,7 +829,6 @@ async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: ( 1, { - "name": TEST_OBJECT_ID, "state": "{{ is_state('switch.test_state', 'on') }}", "turn_on": [], "turn_off": [], @@ -1235,11 +838,7 @@ async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: ], ) @pytest.mark.parametrize( - "style", - [ - ConfigurationStyle.MODERN, - ConfigurationStyle.TRIGGER, - ], + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) @pytest.mark.usefixtures("setup_switch") async def test_optimistic_option(hass: HomeAssistant) -> None: @@ -1247,17 +846,17 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF await hass.services.async_call( switch.DOMAIN, "turn_on", - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) @@ -1266,7 +865,7 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF @@ -1276,7 +875,6 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: ( 1, { - "name": TEST_OBJECT_ID, "state": "{{ is_state('switch.test_state', 'on') }}", "turn_on": [], "turn_off": [], @@ -1298,9 +896,9 @@ async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: await hass.services.async_call( switch.DOMAIN, "turn_on", - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_SWITCH.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == expected diff --git a/tests/components/template/test_update.py b/tests/components/template/test_update.py index 61fbfeede7a..cc2a46c5efe 100644 --- a/tests/components/template/test_update.py +++ b/tests/components/template/test_update.py @@ -17,14 +17,17 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from .conftest import ( ConfigurationStyle, + TemplatePlatformSetup, async_get_flow_preview_state, - async_setup_modern_state_format, - async_setup_modern_trigger_format, make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, ) from tests.common import ( @@ -34,25 +37,23 @@ from tests.common import ( ) from tests.conftest import WebSocketGenerator -TEST_OBJECT_ID = "template_update" -TEST_ENTITY_ID = f"update.{TEST_OBJECT_ID}" TEST_INSTALLED_SENSOR = "sensor.installed_update" TEST_LATEST_SENSOR = "sensor.latest_update" TEST_SENSOR_ID = "sensor.test_update" -TEST_STATE_TRIGGER = make_test_trigger( - TEST_INSTALLED_SENSOR, TEST_LATEST_SENSOR, TEST_SENSOR_ID -) TEST_INSTALLED_TEMPLATE = "{{ '1.0' }}" TEST_LATEST_TEMPLATE = "{{ '2.0' }}" +TEST_UPDATE = TemplatePlatformSetup( + update.DOMAIN, + None, + "template_update", + make_test_trigger(TEST_INSTALLED_SENSOR, TEST_LATEST_SENSOR, TEST_SENSOR_ID), +) + TEST_UPDATE_CONFIG = { "installed_version": TEST_INSTALLED_TEMPLATE, "latest_version": TEST_LATEST_TEMPLATE, } -TEST_UNIQUE_ID_CONFIG = { - **TEST_UPDATE_CONFIG, - "unique_id": "not-so-unique-anymore", -} INSTALL_ACTION = { "install": { @@ -67,23 +68,6 @@ INSTALL_ACTION = { } -async def async_setup_config( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - config: dict[str, Any], - extra_config: dict[str, Any] | None, -) -> None: - """Do setup of update integration.""" - config = {**config, **extra_config} if extra_config else config - if style == ConfigurationStyle.MODERN: - await async_setup_modern_state_format(hass, update.DOMAIN, count, config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_modern_trigger_format( - hass, update.DOMAIN, TEST_STATE_TRIGGER, count, config - ) - - @pytest.fixture async def setup_base( hass: HomeAssistant, @@ -92,13 +76,7 @@ async def setup_base( config: dict[str, Any], ) -> None: """Do setup of update integration.""" - await async_setup_config( - hass, - count, - style, - config, - None, - ) + await setup_entity(hass, TEST_UPDATE, style, count, config) @pytest.fixture @@ -111,16 +89,16 @@ async def setup_update( extra_config: dict[str, Any] | None, ) -> None: """Do setup of update integration.""" - await async_setup_config( + await setup_entity( hass, - count, + TEST_UPDATE, style, + count, { - "name": TEST_OBJECT_ID, "installed_version": installed_template, "latest_version": latest_template, }, - extra_config, + extra_config=extra_config, ) @@ -134,16 +112,18 @@ async def setup_single_attribute_update( attribute_template: str, ) -> None: """Do setup of update platform testing a single attribute.""" - await async_setup_config( + await setup_entity( hass, - 1, + TEST_UPDATE, style, + 1, { - "name": TEST_OBJECT_ID, "installed_version": installed_template, "latest_version": latest_template, }, - {attribute: attribute_template} if attribute and attribute_template else {}, + extra_config=( + {attribute: attribute_template} if attribute and attribute_template else {} + ), ) @@ -153,7 +133,7 @@ async def test_legacy_platform_config(hass: HomeAssistant) -> None: assert await async_setup_component( hass, update.DOMAIN, - {"update": {"platform": "template", "updates": {TEST_OBJECT_ID: {}}}}, + {"update": {"platform": "template", "updates": {"anything": {}}}}, ) await hass.async_block_till_done() @@ -172,7 +152,7 @@ async def test_setup_config_entry( data={}, domain=template.DOMAIN, options={ - "name": TEST_OBJECT_ID, + "name": TEST_UPDATE.object_id, "template_type": update.DOMAIN, **TEST_UPDATE_CONFIG, }, @@ -183,7 +163,7 @@ async def test_setup_config_entry( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state == snapshot @@ -210,7 +190,7 @@ async def test_device_id( data={}, domain=template.DOMAIN, options={ - "name": TEST_OBJECT_ID, + "name": TEST_UPDATE.object_id, "template_type": update.DOMAIN, **TEST_UPDATE_CONFIG, "device_id": device_entry.id, @@ -222,7 +202,7 @@ async def test_device_id( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - template_entity = entity_registry.async_get(TEST_ENTITY_ID) + template_entity = entity_registry.async_get(TEST_UPDATE.entity_id) assert template_entity is not None assert template_entity.device_id == device_entry.id @@ -249,7 +229,7 @@ async def test_syntax_error( expected_state: str, ) -> None: """Test template update with render error.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.state == expected_state @@ -283,7 +263,7 @@ async def test_update_templates( hass.states.async_set(TEST_LATEST_SENSOR, latest) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state.state == expected assert state.attributes["installed_version"] == installed @@ -319,7 +299,7 @@ async def test_installed_and_latest_template_updates_from_entity( hass.states.async_set(TEST_LATEST_SENSOR, "2.0") await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state.state == STATE_ON assert state.attributes["installed_version"] == "1.0" @@ -329,7 +309,7 @@ async def test_installed_and_latest_template_updates_from_entity( hass.states.async_set(TEST_LATEST_SENSOR, "2.0") await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state.state == STATE_OFF assert state.attributes["installed_version"] == "2.0" @@ -339,7 +319,7 @@ async def test_installed_and_latest_template_updates_from_entity( hass.states.async_set(TEST_LATEST_SENSOR, "3.0") await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state.state == STATE_ON assert state.attributes["installed_version"] == "2.0" @@ -374,7 +354,7 @@ async def test_installed_version_template( hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state.state == expected assert state.attributes["installed_version"] == expected_attr @@ -408,7 +388,7 @@ async def test_latest_version_template( hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state is not None assert state.state == expected assert state.attributes["latest_version"] == expected_attr @@ -439,7 +419,7 @@ async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.services.async_call( update.DOMAIN, update.SERVICE_INSTALL, - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_UPDATE.entity_id}, blocking=True, ) await hass.async_block_till_done() @@ -447,7 +427,7 @@ async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> # verify assert len(calls) == 1 assert calls[-1].data["action"] == "install" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_UPDATE.entity_id hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") hass.states.async_set(TEST_LATEST_SENSOR, "2.0") @@ -458,7 +438,7 @@ async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.services.async_call( update.DOMAIN, update.SERVICE_INSTALL, - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_UPDATE.entity_id}, blocking=True, ) await hass.async_block_till_done() @@ -466,7 +446,7 @@ async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> # verify assert len(calls) == 1 assert calls[-1].data["action"] == "install" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_UPDATE.entity_id @pytest.mark.parametrize( @@ -501,13 +481,13 @@ async def test_entity_picture_and_icon_templates( state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes.get(key) in ("", None) state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes[key] == expected @@ -534,13 +514,13 @@ async def test_entity_picture_uses_default(hass: HomeAssistant) -> None: state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes[ATTR_ENTITY_PICTURE] == "foo.png" state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert ( state.attributes[ATTR_ENTITY_PICTURE] @@ -582,7 +562,7 @@ async def test_in_process_template( state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes.get(attribute) == expected assert error is None or error in caplog_setup_text or error in caplog.text @@ -621,7 +601,7 @@ async def test_release_summary_and_title_templates( state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes.get(attribute) == expected @@ -674,7 +654,7 @@ async def test_release_url_template( state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes.get(attribute) == expected assert error is None or error in caplog_setup_text or error in caplog.text @@ -714,7 +694,7 @@ async def test_update_percent_template( state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes.get(attribute) == expected assert error is None or error in caplog_setup_text or error in caplog.text @@ -740,7 +720,7 @@ async def test_optimistic_in_progress_with_update_percent_template( ) -> None: """Test optimistic in_progress attribute with update percent templates.""" # Ensure trigger entities trigger. - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes["in_progress"] is False assert state.attributes["update_percentage"] is None @@ -748,14 +728,14 @@ async def test_optimistic_in_progress_with_update_percent_template( state = hass.states.async_set(TEST_SENSOR_ID, i) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes["in_progress"] is True assert state.attributes["update_percentage"] == i state = hass.states.async_set(TEST_SENSOR_ID, STATE_UNAVAILABLE) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes["in_progress"] is False assert state.attributes["update_percentage"] is None @@ -821,13 +801,13 @@ async def test_supported_features( state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.attributes["supported_features"] == supported_feature await hass.services.async_call( update.DOMAIN, update.SERVICE_INSTALL, - {"entity_id": TEST_ENTITY_ID, **action_data}, + {"entity_id": TEST_UPDATE.entity_id, **action_data}, blocking=True, ) await hass.async_block_till_done() @@ -836,7 +816,7 @@ async def test_supported_features( assert len(calls) == 1 data = calls[-1].data assert data["action"] == "install" - assert data["caller"] == TEST_ENTITY_ID + assert data["caller"] == TEST_UPDATE.entity_id assert data["backup"] == expected_backup assert data["specific_version"] == expected_version @@ -861,19 +841,19 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: hass.states.async_set(TEST_SENSOR_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.state != STATE_UNAVAILABLE hass.states.async_set(TEST_SENSOR_ID, STATE_UNAVAILABLE) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.state == STATE_UNAVAILABLE hass.states.async_set(TEST_SENSOR_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.state != STATE_UNAVAILABLE @@ -902,7 +882,7 @@ async def test_invalid_availability_template_keeps_component_available( hass.states.async_set(TEST_SENSOR_ID, "anything") await hass.async_block_till_done() - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_UPDATE.entity_id).state != STATE_UNAVAILABLE error = "UndefinedError: 'x' is undefined" assert error in caplog_setup_text or error in caplog.text @@ -916,7 +896,7 @@ async def test_invalid_availability_template_keeps_component_available( "template": { "trigger": {"platform": "event", "event_type": "test_event"}, "update": { - "name": TEST_OBJECT_ID, + "name": TEST_UPDATE.object_id, "installed_version": "{{ trigger.event.data.action }}", "latest_version": "{{ '1.0.2' }}", "picture": "{{ '/local/dogs.png' }}", @@ -941,7 +921,7 @@ async def test_trigger_entity_restore_state( "skipped_version": "1.0.1", } fake_state = State( - TEST_ENTITY_ID, + TEST_UPDATE.entity_id, STATE_OFF, restored_attributes, ) @@ -957,7 +937,7 @@ async def test_trigger_entity_restore_state( await hass.async_start() await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.state == STATE_OFF for attr, value in restored_attributes.items(): assert state.attributes[attr] == value @@ -965,106 +945,37 @@ async def test_trigger_entity_restore_state( hass.bus.async_fire("test_event", {"action": "1.0.0"}) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_UPDATE.entity_id) assert state.state == STATE_ON assert state.attributes["icon"] == "mdi:pirate" assert state.attributes["entity_picture"] == "/local/dogs.png" -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("config", [TEST_UPDATE_CONFIG]) @pytest.mark.parametrize( - ("updates", "style"), - [ - ( - [ - { - "name": "test_template_event_01", - **TEST_UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_event_02", - **TEST_UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ( - [ - { - "name": "test_template_event_01", - **TEST_UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_event_02", - **TEST_UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.TRIGGER, - ), - ], + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) async def test_unique_id( - hass: HomeAssistant, count: int, updates: list[dict], style: ConfigurationStyle + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType ) -> None: """Test unique_id option only creates one update entity per id.""" - config = {"update": updates} - if style == ConfigurationStyle.TRIGGER: - config = {**config, **TEST_STATE_TRIGGER} - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - {"template": config}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("update")) == 1 + await setup_and_test_unique_id(hass, TEST_UPDATE, style, config) +@pytest.mark.parametrize("config", [TEST_UPDATE_CONFIG]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, ) -> None: """Test unique_id option creates one update entity per nested id.""" - - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "update": [ - { - "name": "test_a", - **TEST_UPDATE_CONFIG, - "unique_id": "a", - }, - { - "name": "test_b", - **TEST_UPDATE_CONFIG, - "unique_id": "b", - }, - ], - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("update")) == 2 - - entry = entity_registry.async_get("update.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("update.test_b") - assert entry - assert entry.unique_id == "x-b" + await setup_and_test_nested_unique_id( + hass, TEST_UPDATE, style, entity_registry, config + ) async def test_flow_preview( diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ac643318c57..f53c1699549 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -17,37 +17,39 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType -from .conftest import ConfigurationStyle, async_get_flow_preview_state +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + async_get_flow_preview_state, + async_trigger, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) -from tests.common import MockConfigEntry, assert_setup_component +from tests.common import MockConfigEntry from tests.components.vacuum import common from tests.typing import WebSocketGenerator -TEST_OBJECT_ID = "test_vacuum" -TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" - TEST_STATE_SENSOR = "sensor.test_state" TEST_SPEED_SENSOR = "sensor.test_fan_speed" TEST_BATTERY_LEVEL_SENSOR = "sensor.test_battery_level" TEST_AVAILABILITY_ENTITY = "availability_state.state" -TEST_STATE_TRIGGER = { - "trigger": { - "trigger": "state", - "entity_id": [ - TEST_STATE_SENSOR, - TEST_SPEED_SENSOR, - TEST_BATTERY_LEVEL_SENSOR, - TEST_AVAILABILITY_ENTITY, - ], - }, - "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, - "action": [ - {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} - ], -} +TEST_VACUUM = TemplatePlatformSetup( + vacuum.DOMAIN, + "vacuums", + "test_vacuum", + make_test_trigger( + TEST_STATE_SENSOR, + TEST_SPEED_SENSOR, + TEST_BATTERY_LEVEL_SENSOR, + TEST_AVAILABILITY_ENTITY, + ), +) START_ACTION = { "start": { @@ -114,70 +116,16 @@ def _verify( hass: HomeAssistant, expected_state: str, expected_battery_level: int | None = None, - expected_fan_speed: int | None = None, + expected_fan_speed: str | None = None, ) -> None: """Verify vacuum's state and speed.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) attributes = state.attributes assert state.state == expected_state assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level assert attributes.get(ATTR_FAN_SPEED) == expected_fan_speed -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] -) -> None: - """Do setup of vacuum integration via new format.""" - config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}} - - with assert_setup_component(count, vacuum.DOMAIN): - assert await async_setup_component( - hass, - vacuum.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_modern_format( - hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] -) -> None: - """Do setup of vacuum integration via modern format.""" - config = {"template": {"vacuum": vacuum_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] -) -> None: - """Do setup of vacuum integration via trigger format.""" - config = {"template": {"vacuum": vacuum_config, **TEST_STATE_TRIGGER}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - @pytest.fixture async def setup_vacuum( hass: HomeAssistant, @@ -186,12 +134,7 @@ async def setup_vacuum( vacuum_config: dict[str, Any], ) -> None: """Do setup of number integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, vacuum_config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, vacuum_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, vacuum_config) + await setup_entity(hass, TEST_VACUUM, style, count, vacuum_config) @pytest.fixture @@ -203,18 +146,9 @@ async def setup_test_vacuum_with_extra_config( extra_config: dict[str, Any], ) -> None: """Do setup of number integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, count, {TEST_OBJECT_ID: {**vacuum_config, **extra_config}} - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} - ) + await setup_entity( + hass, TEST_VACUUM, style, count, vacuum_config, extra_config=extra_config + ) @pytest.fixture @@ -225,37 +159,9 @@ async def setup_state_vacuum( state_template: str, ): """Do setup of vacuum integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - "value_template": state_template, - **TEMPLATE_VACUUM_ACTIONS, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "state": state_template, - **TEMPLATE_VACUUM_ACTIONS, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "state": state_template, - **TEMPLATE_VACUUM_ACTIONS, - }, - ) + await setup_entity( + hass, TEST_VACUUM, style, count, TEMPLATE_VACUUM_ACTIONS, state_template + ) @pytest.fixture @@ -267,40 +173,7 @@ async def setup_base_vacuum( extra_config: dict, ): """Do setup of vacuum integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - state_config = {"value_template": state_template} if state_template else {} - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **state_config, - **extra_config, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - state_config = {"state": state_template} if state_template else {} - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **state_config, - **extra_config, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - state_config = {"state": state_template} if state_template else {} - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **state_config, - **extra_config, - }, - ) + await setup_entity(hass, TEST_VACUUM, style, count, extra_config, state_template) @pytest.fixture @@ -314,47 +187,16 @@ async def setup_single_attribute_state_vacuum( extra_config: dict, ) -> None: """Do setup of vacuum integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - if style == ConfigurationStyle.LEGACY: - state_config = {"value_template": state_template} if state_template else {} - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **state_config, - **TEMPLATE_VACUUM_ACTIONS, - **extra, - **extra_config, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - state_config = {"state": state_template} if state_template else {} - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **state_config, - **TEMPLATE_VACUUM_ACTIONS, - **extra, - **extra_config, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - state_config = {"state": state_template} if state_template else {} - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **state_config, - **TEMPLATE_VACUUM_ACTIONS, - **extra, - **extra_config, - }, - ) + config = {attribute: attribute_template} if attribute and attribute_template else {} + await setup_entity( + hass, + TEST_VACUUM, + style, + count, + {**config, **TEMPLATE_VACUUM_ACTIONS}, + state_template, + extra_config, + ) @pytest.fixture @@ -366,43 +208,15 @@ async def setup_attributes_state_vacuum( attributes: dict, ) -> None: """Do setup of vacuum integration testing a single attribute.""" - if style == ConfigurationStyle.LEGACY: - state_config = {"value_template": state_template} if state_template else {} - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - "attribute_templates": attributes, - **state_config, - **TEMPLATE_VACUUM_ACTIONS, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - state_config = {"state": state_template} if state_template else {} - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "attributes": attributes, - **state_config, - **TEMPLATE_VACUUM_ACTIONS, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - state_config = {"state": state_template} if state_template else {} - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "attributes": attributes, - **state_config, - **TEMPLATE_VACUUM_ACTIONS, - }, - ) + await setup_entity( + hass, + TEST_VACUUM, + style, + count, + TEMPLATE_VACUUM_ACTIONS, + state_template, + attributes=attributes, + ) @pytest.mark.parametrize("count", [1]) @@ -582,10 +396,7 @@ async def test_battery_level_template( hass: HomeAssistant, expected: int | None ) -> None: """Test templates with values from other entities.""" - # Ensure trigger entity templates are rendered - hass.states.async_set(TEST_STATE_SENSOR, None) - await hass.async_block_till_done() - + await async_trigger(hass, TEST_STATE_SENSOR) _verify(hass, STATE_UNKNOWN, expected) @@ -609,18 +420,16 @@ async def test_battery_level_template_repair( caplog: pytest.LogCaptureFixture, ) -> None: """Test battery_level template raises issue.""" - # Ensure trigger entity templates are rendered - hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_SENSOR, VacuumActivity.DOCKED) assert len(issue_registry.issues) == issue_count issue = issue_registry.async_get_issue( - "template", f"deprecated_battery_level_{TEST_ENTITY_ID}" + "template", f"deprecated_battery_level_{TEST_VACUUM.entity_id}" ) assert issue.domain == "template" assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID - assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + assert issue.translation_placeholders["entity_name"] == TEST_VACUUM.object_id + assert issue.translation_placeholders["entity_id"] == TEST_VACUUM.entity_id assert "Detected that integration 'template' is setting the" not in caplog.text @@ -656,10 +465,7 @@ async def test_battery_level_template_repair( @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: """Test templates with values from other entities.""" - # Ensure trigger entity templates are rendered - hass.states.async_set(TEST_STATE_SENSOR, None) - await hass.async_block_till_done() - + await async_trigger(hass, TEST_STATE_SENSOR) _verify(hass, STATE_UNKNOWN, None, expected) @@ -685,13 +491,13 @@ async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_icon_template(hass: HomeAssistant, expected: int) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes.get("icon") == expected hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes["icon"] == "mdi:check" @@ -717,13 +523,13 @@ async def test_icon_template(hass: HomeAssistant, expected: int) -> None: @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_picture_template(hass: HomeAssistant, expected: int) -> None: """Test picture template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes.get("entity_picture") == expected hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes["entity_picture"] == "local/vacuum.png" @@ -755,14 +561,14 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_VACUUM.entity_id).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_VACUUM.entity_id).state == STATE_UNAVAILABLE @pytest.mark.parametrize("extra_config", [{}]) @@ -789,12 +595,8 @@ async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - - # Ensure state change triggers trigger entity. - hass.states.async_set(TEST_STATE_SENSOR, None) - await hass.async_block_till_done() - - assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE + await async_trigger(hass, TEST_STATE_SENSOR) + assert hass.states.get(TEST_VACUUM.entity_id) != STATE_UNAVAILABLE err = "'x' is undefined" assert err in caplog_setup_text or err in caplog.text @@ -815,13 +617,13 @@ async def test_invalid_availability_template_keeps_component_available( @pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_attribute_templates(hass: HomeAssistant) -> None: """Test attribute_templates template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes["test_attribute"] == "It ." hass.states.async_set(TEST_STATE_SENSOR, "Works") await hass.async_block_till_done() - await async_update_entity(hass, TEST_ENTITY_ID) - state = hass.states.get(TEST_ENTITY_ID) + await async_update_entity(hass, TEST_VACUUM.entity_id) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes["test_attribute"] == "It Works." @@ -853,59 +655,32 @@ async def test_invalid_attribute_template( assert err in caplog_setup_text or err in caplog.text -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("config", [TEMPLATE_VACUUM_ACTIONS]) @pytest.mark.parametrize( - ("style", "vacuum_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_vacuum_01": { - "value_template": "{{ true }}", - **UNIQUE_ID_CONFIG, - }, - "test_template_vacuum_02": { - "value_template": "{{ false }}", - **UNIQUE_ID_CONFIG, - }, - }, - ), - ( - ConfigurationStyle.MODERN, - [ - { - "name": "test_template_vacuum_01", - "state": "{{ true }}", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_vacuum_02", - "state": "{{ false }}", - **UNIQUE_ID_CONFIG, - }, - ], - ), - ( - ConfigurationStyle.TRIGGER, - [ - { - "name": "test_template_vacuum_01", - "state": "{{ true }}", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_vacuum_02", - "state": "{{ false }}", - **UNIQUE_ID_CONFIG, - }, - ], - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_vacuum") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: """Test unique_id option only creates one vacuum per id.""" - assert len(hass.states.async_all("vacuum")) == 1 + await setup_and_test_unique_id(hass, TEST_VACUUM, style, config) + + +@pytest.mark.parametrize("config", [TEMPLATE_VACUUM_ACTIONS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_nested_unique_id( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, +) -> None: + """Test a template unique_id propagates to vacuum unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_VACUUM, style, entity_registry, config + ) @pytest.mark.parametrize( @@ -920,32 +695,32 @@ async def test_unused_services(hass: HomeAssistant) -> None: """Test calling unused services raises.""" # Pause vacuum with pytest.raises(HomeAssistantError): - await common.async_pause(hass, TEST_ENTITY_ID) + await common.async_pause(hass, TEST_VACUUM.entity_id) await hass.async_block_till_done() # Stop vacuum with pytest.raises(HomeAssistantError): - await common.async_stop(hass, TEST_ENTITY_ID) + await common.async_stop(hass, TEST_VACUUM.entity_id) await hass.async_block_till_done() # Return vacuum to base with pytest.raises(HomeAssistantError): - await common.async_return_to_base(hass, TEST_ENTITY_ID) + await common.async_return_to_base(hass, TEST_VACUUM.entity_id) await hass.async_block_till_done() # Spot cleaning with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, TEST_ENTITY_ID) + await common.async_clean_spot(hass, TEST_VACUUM.entity_id) await hass.async_block_till_done() # Locate vacuum with pytest.raises(HomeAssistantError): - await common.async_locate(hass, TEST_ENTITY_ID) + await common.async_locate(hass, TEST_VACUUM.entity_id) await hass.async_block_till_done() # Set fan's speed with pytest.raises(HomeAssistantError): - await common.async_set_fan_speed(hass, "medium", TEST_ENTITY_ID) + await common.async_set_fan_speed(hass, "medium", TEST_VACUUM.entity_id) await hass.async_block_till_done() _verify(hass, STATE_UNKNOWN, None) @@ -979,7 +754,7 @@ async def test_state_services( await hass.services.async_call( "vacuum", action, - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_VACUUM.entity_id}, blocking=True, ) await hass.async_block_till_done() @@ -987,7 +762,7 @@ async def test_state_services( # verify assert len(calls) == 1 assert calls[-1].data["action"] == action - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_VACUUM.entity_id @pytest.mark.parametrize( @@ -1016,23 +791,23 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N """Test set valid fan speed.""" # Set vacuum's fan speed to high - await common.async_set_fan_speed(hass, "high", TEST_ENTITY_ID) + await common.async_set_fan_speed(hass, "high", TEST_VACUUM.entity_id) await hass.async_block_till_done() # verify assert len(calls) == 1 assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_VACUUM.entity_id assert calls[-1].data["fan_speed"] == "high" # Set fan's speed to medium - await common.async_set_fan_speed(hass, "medium", TEST_ENTITY_ID) + await common.async_set_fan_speed(hass, "medium", TEST_VACUUM.entity_id) await hass.async_block_till_done() # verify assert len(calls) == 2 assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_VACUUM.entity_id assert calls[-1].data["fan_speed"] == "medium" @@ -1069,68 +844,26 @@ async def test_set_invalid_fan_speed( """Test set invalid fan speed when fan has valid speed.""" # Set vacuum's fan speed to high - await common.async_set_fan_speed(hass, "high", TEST_ENTITY_ID) + await common.async_set_fan_speed(hass, "high", TEST_VACUUM.entity_id) await hass.async_block_till_done() # verify assert len(calls) == 1 assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_VACUUM.entity_id assert calls[-1].data["fan_speed"] == "high" # Set vacuum's fan speed to 'invalid' - await common.async_set_fan_speed(hass, "invalid", TEST_ENTITY_ID) + await common.async_set_fan_speed(hass, "invalid", TEST_VACUUM.entity_id) await hass.async_block_till_done() # verify fan speed is unchanged assert len(calls) == 1 assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["caller"] == TEST_VACUUM.entity_id assert calls[-1].data["fan_speed"] == "high" -async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test a template unique_id propagates to switch unique_ids.""" - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "vacuum": [ - { - **TEMPLATE_VACUUM_ACTIONS, - "name": "test_a", - "unique_id": "a", - }, - { - **TEMPLATE_VACUUM_ACTIONS, - "name": "test_b", - "unique_id": "b", - }, - ], - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("vacuum")) == 2 - - entry = entity_registry.async_get("vacuum.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("vacuum.test_b") - assert entry - assert entry.unique_id == "x-b" - - @pytest.mark.parametrize(("count", "vacuum_config"), [(1, {"start": []})]) @pytest.mark.parametrize( "style", @@ -1177,16 +910,16 @@ async def test_nested_unique_id( ), ], ) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") async def test_empty_action_config( hass: HomeAssistant, supported_features: VacuumEntityFeature, - setup_test_vacuum_with_extra_config, ) -> None: """Test configuration with empty script.""" - await common.async_start(hass, TEST_ENTITY_ID) + await common.async_start(hass, TEST_VACUUM.entity_id) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes["supported_features"] == ( VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features ) @@ -1197,7 +930,7 @@ async def test_empty_action_config( [ ( 1, - {"name": TEST_OBJECT_ID, "start": [], **TEMPLATE_VACUUM_ACTIONS}, + {"name": TEST_VACUUM.object_id, "start": [], **TEMPLATE_VACUUM_ACTIONS}, ) ], ) @@ -1227,12 +960,12 @@ async def test_assumed_optimistic( await hass.services.async_call( vacuum.DOMAIN, service, - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_VACUUM.entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.state == expected @@ -1242,7 +975,7 @@ async def test_assumed_optimistic( ( 1, { - "name": TEST_OBJECT_ID, + "name": TEST_VACUUM.object_id, "state": "{{ states('sensor.test_state') }}", "start": [], **TEMPLATE_VACUUM_ACTIONS, @@ -1276,18 +1009,18 @@ async def test_optimistic_option( hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.state == VacuumActivity.DOCKED await hass.services.async_call( vacuum.DOMAIN, service, - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_VACUUM.entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.state == expected hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.RETURNING) @@ -1296,7 +1029,7 @@ async def test_optimistic_option( hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.state == VacuumActivity.DOCKED @@ -1306,7 +1039,7 @@ async def test_optimistic_option( ( 1, { - "name": TEST_OBJECT_ID, + "name": TEST_VACUUM.object_id, "state": "{{ states('sensor.test_state') }}", "start": [], **TEMPLATE_VACUUM_ACTIONS, @@ -1339,12 +1072,12 @@ async def test_not_optimistic( await hass.services.async_call( vacuum.DOMAIN, service, - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_VACUUM.entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_VACUUM.entity_id) assert state.state == STATE_UNKNOWN diff --git a/tests/components/text/test_trigger.py b/tests/components/text/test_trigger.py index aa8b66ec2df..89e4a1cdc37 100644 --- a/tests/components/text/test_trigger.py +++ b/tests/components/text/test_trigger.py @@ -43,31 +43,6 @@ async def target_texts(hass: HomeAssistant) -> list[str]: return (await target_entities(hass, "text"))["included"] -@pytest.mark.parametrize( - "trigger_key", - [ - "alarm_control_panel.armed", - "alarm_control_panel.armed_away", - "alarm_control_panel.armed_home", - "alarm_control_panel.armed_night", - "alarm_control_panel.armed_vacation", - "alarm_control_panel.disarmed", - "alarm_control_panel.triggered", - ], -) -async def test_alarm_control_panel_triggers_gated_by_labs_flag( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str -) -> None: - """Test the ACP triggers are gated by the labs flag.""" - await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) - assert ( - "Unnamed automation failed to setup triggers and has been disabled: Trigger " - f"'{trigger_key}' requires the experimental 'New triggers and conditions' " - "feature to be enabled in Home Assistant Labs settings (feature flag: " - "'new_triggers_conditions')" - ) in caplog.text - - @pytest.mark.parametrize("trigger_key", ["text.changed"]) async def test_text_triggers_gated_by_labs_flag( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index 3c27f09d396..8281321c4e3 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -204,7 +204,7 @@ async def test_config_flow_preview_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] is None assert result["preview"] == "threshold" @@ -259,7 +259,7 @@ async def test_options_flow_preview( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "threshold" @@ -309,7 +309,7 @@ async def test_options_flow_sensor_preview_config_entry_removed( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None assert result["preview"] == "threshold" diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py index dcc23307050..9e5c132c99d 100644 --- a/tests/components/tibber/test_init.py +++ b/tests/components/tibber/test_init.py @@ -13,9 +13,9 @@ async def test_entry_unload( ) -> None: """Test unloading the entry.""" entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "tibber") - assert entry.state == ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entry.entry_id) mock_tibber_setup.rt_disconnect.assert_called_once() await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state == ConfigEntryState.NOT_LOADED + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index b6cb8f91f82..8404aa9aaf9 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -151,7 +151,7 @@ async def test_reconfigure_walkthrough( result["flow_id"], user_input={CONF_HOST: "127.0.0.4"} ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_HOST] == "127.0.0.4" @@ -171,7 +171,7 @@ async def test_reconfigure_error_then_fix( result["flow_id"], user_input={CONF_HOST: "127.0.0.5"} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" @@ -180,7 +180,7 @@ async def test_reconfigure_error_then_fix( result["flow_id"], user_input={CONF_HOST: "127.0.0.4"} ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_HOST] == "127.0.0.4" @@ -204,7 +204,7 @@ async def test_reconfigure_duplicate_ip( result["flow_id"], user_input={CONF_HOST: "127.0.0.6"} ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert config_entry.data[CONF_HOST] == "127.0.0.1" diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index 45f801e9827..b876a096a10 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -86,6 +86,7 @@ async def mock_omada_site_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMoc site_client.get_known_clients.return_value = async_empty() site_client.get_connected_clients.return_value = async_empty() + site_client.reconnect_client = AsyncMock() return site_client @@ -159,6 +160,7 @@ def mock_omada_client(mock_omada_site_client: AsyncMock) -> Generator[MagicMock] client = client_mock.return_value client.get_site_client.return_value = mock_omada_site_client + client.login = AsyncMock() yield client diff --git a/tests/components/tplink_omada/test_init.py b/tests/components/tplink_omada/test_init.py index 762168df9d6..446ea71b427 100644 --- a/tests/components/tplink_omada/test_init.py +++ b/tests/components/tplink_omada/test_init.py @@ -2,8 +2,17 @@ from unittest.mock import MagicMock +import pytest +from tplink_omada_client.exceptions import ( + ConnectionFailed, + OmadaClientException, + UnsupportedControllerVersion, +) + from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -17,6 +26,41 @@ MOCK_ENTRY_DATA = { } +@pytest.mark.parametrize( + ("side_effect", "entry_state"), + [ + ( + UnsupportedControllerVersion("4.0.0"), + ConfigEntryState.SETUP_ERROR, + ), + ( + ConnectionFailed(), + ConfigEntryState.SETUP_RETRY, + ), + ( + OmadaClientException(), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_login_failed_raises_configentryauthfailed( + hass: HomeAssistant, + mock_omada_client: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: OmadaClientException, + entry_state: ConfigEntryState, +) -> None: + """Test setup entry with login failed raises ConfigEntryAuthFailed.""" + mock_omada_client.login.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == entry_state + + async def test_missing_devices_removed_at_startup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -45,3 +89,73 @@ async def test_missing_devices_removed_at_startup( await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) is None + + +async def test_service_reconnect_client( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + mock_omada_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconnect client service.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mac = "AA:BB:CC:DD:EE:FF" + await hass.services.async_call( + DOMAIN, + "reconnect_client", + {"mac": mac}, + blocking=True, + ) + + mock_omada_site_client.reconnect_client.assert_awaited_once_with(mac) + + +async def test_service_reconnect_failed_raises_servicevalidationerror( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + mock_omada_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconnect with missing mac address raises ServiceValidationError.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "reconnect_client", + {}, + blocking=True, + ) + + +async def test_service_reconnect_failed_raises_homeassistanterror( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + mock_omada_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconnect client service raises the right kind of exception on service failure.""" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mac = "AA:BB:CC:DD:EE:FF" + mock_omada_site_client.reconnect_client.side_effect = OmadaClientException + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "reconnect_client", + {"mac": mac}, + blocking=True, + ) + + mock_omada_site_client.reconnect_client.assert_awaited_once_with(mac) diff --git a/tests/components/transmission/conftest.py b/tests/components/transmission/conftest.py index 4e469b4fd79..0390981db92 100644 --- a/tests/components/transmission/conftest.py +++ b/tests/components/transmission/conftest.py @@ -101,3 +101,10 @@ def mock_torrent(): return Torrent(fields=torrent_data) return _create_mock_torrent + + +@pytest.fixture(autouse=True) +def patch_sleep() -> Generator[None]: + """Fixture to remove sleep in tests.""" + with patch("homeassistant.components.transmission.switch.AFTER_WRITE_SLEEP", 0): + yield diff --git a/tests/components/transmission/test_services.py b/tests/components/transmission/test_services.py index 45061e7b30a..52ff3e2aaef 100644 --- a/tests/components/transmission/test_services.py +++ b/tests/components/transmission/test_services.py @@ -31,7 +31,7 @@ async def test_service_config_entry_not_loaded_state( """Test service call when config entry is in failed state.""" mock_config_entry.add_to_hass(hass) - assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED with pytest.raises(ServiceValidationError, match="service_not_found"): await hass.services.async_call( diff --git a/tests/components/transmission/test_switch.py b/tests/components/transmission/test_switch.py index 9fbae8f4e5c..11b10910cc7 100644 --- a/tests/components/transmission/test_switch.py +++ b/tests/components/transmission/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from transmission_rpc.session import Session from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -101,10 +102,10 @@ async def test_on_off_switch_with_torrents( @pytest.mark.parametrize( - ("service", "alt_speed_enabled"), + ("service", "alt_speed_enabled", "expected_state"), [ - (SERVICE_TURN_ON, True), - (SERVICE_TURN_OFF, False), + (SERVICE_TURN_ON, True, "on"), + (SERVICE_TURN_OFF, False, "off"), ], ) async def test_turtle_mode_switch( @@ -113,10 +114,23 @@ async def test_turtle_mode_switch( mock_config_entry: MockConfigEntry, service: str, alt_speed_enabled: bool, + expected_state: str, ) -> None: """Test turtle mode switch.""" client = mock_transmission_client.return_value + current_alt_speed = not alt_speed_enabled + + def set_session_side_effect(**kwargs): + nonlocal current_alt_speed + if "alt_speed_enabled" in kwargs: + current_alt_speed = kwargs["alt_speed_enabled"] + + client.set_session.side_effect = set_session_side_effect + client.get_session.side_effect = lambda: Session( + fields={"alt-speed-enabled": current_alt_speed} + ) + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -129,3 +143,7 @@ async def test_turtle_mode_switch( ) client.set_session.assert_called_once_with(alt_speed_enabled=alt_speed_enabled) + + state = hass.states.get("switch.transmission_turtle_mode") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 30a76ba3809..d74fae6be7d 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -176,6 +176,7 @@ async def _create_device(hass: HomeAssistant, mock_device_code: str) -> Customer if device.update_time: device.update_time = int(dt_util.as_timestamp(device.update_time)) device.support_local = details.get("support_local") + device.local_strategy = details.get("local_strategy") device.mqtt_connected = details.get("mqtt_connected") device.function = { diff --git a/tests/components/tuya/fixtures/msp_5t7esmqqh92ssbe5.json b/tests/components/tuya/fixtures/msp_5t7esmqqh92ssbe5.json new file mode 100644 index 00000000000..6b8058ba978 --- /dev/null +++ b/tests/components/tuya/fixtures/msp_5t7esmqqh92ssbe5.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Slimme kattenbak", + "category": "msp", + "product_id": "5t7esmqqh92ssbe5", + "product_name": "Automatic Cat Litter Box", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-25T06:46:38+00:00", + "create_time": "2025-07-25T06:46:38+00:00", + "update_time": "2025-07-25T06:46:38+00:00", + "function": { + "deodorization": { + "type": "Boolean", + "value": "{}" + } + }, + "status_range": { + "cat_weight": { + "type": "Integer", + "value": "{\"unit\":\"g\",\"min\":600,\"max\":10000,\"scale\":0,\"step\":1}" + }, + "excretion_times_day": { + "type": "Integer", + "value": "{\"unit\":\"times\",\"min\":0,\"max\":60,\"scale\":0,\"step\":1}" + }, + "excretion_time_day": { + "type": "Integer", + "value": "{\"unit\":\"s\",\"min\":0,\"max\":1800,\"scale\":0,\"step\":1}" + }, + "deodorization": { + "type": "Boolean", + "value": "{}" + }, + "fault": { + "type": "Bitmap", + "value": "{\"label\":[\"motor_fault\",\"program_fault\",\"g_sensor_fault\"],\"maxlen\":3}" + } + }, + "status": { + "cat_weight": 4999, + "excretion_times_day": 1, + "excretion_time_day": 93, + "deodorization": false, + "fault": 0 + }, + "set_up": true, + "support_local": true, + "warnings": null +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 9937b680b2e..286f04b649f 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -651,7 +651,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_platform_setup_and_discovery[climate.itc_308_wifi_thermostat-entry] @@ -713,7 +713,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_platform_setup_and_discovery[climate.kabinet-entry] @@ -1007,7 +1007,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_platform_setup_and_discovery[climate.polotentsosushitel-entry] diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr index 54e31002f16..5901af7c847 100644 --- a/tests/components/tuya/snapshots/test_diagnostics.ambr +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -198,6 +198,7 @@ 'name_by_user': None, }), 'id': '2pxfek1jjrtctiyglam', + 'local_strategy': None, 'mqtt_connected': True, 'name': 'Multifunction alarm', 'online': True, @@ -369,6 +370,7 @@ 'name_by_user': None, }), 'id': 'cwwk68dyfsh2eqi4jbqr', + 'local_strategy': None, 'mqtt_connected': True, 'name': 'Gas sensor', 'online': True, @@ -512,6 +514,7 @@ 'name_by_user': None, }), 'id': 'vrhdtr5fawoiyth9qdt', + 'local_strategy': None, 'mqtt_connected': True, 'name': 'Framboisiers', 'online': True, @@ -644,6 +647,7 @@ 'name_by_user': None, }), 'id': 'cwwk68dyfsh2eqi4jbqr', + 'local_strategy': None, 'name': 'Gas sensor', 'online': True, 'product_id': '4iqe2hsfyd86kwwc', diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index ff8e26e2fb3..09044d6a22e 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -619,6 +619,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[5ebss29hqqmse7t5psm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '5ebss29hqqmse7t5psm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Automatic Cat Litter Box', + 'model_id': '5t7esmqqh92ssbe5', + 'name': 'Slimme kattenbak', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[5gfyvvg48bsxbbnjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 4753fa09335..7253e2f6a4c 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -16914,6 +16914,167 @@ 'state': '77.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.slimme_kattenbak_cat_weight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.slimme_kattenbak_cat_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cat weight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cat_weight', + 'unique_id': 'tuya.5ebss29hqqmse7t5psmcat_weight', + 'unit_of_measurement': 'g', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.slimme_kattenbak_cat_weight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'Slimme kattenbak Cat weight', + 'state_class': , + 'unit_of_measurement': 'g', + }), + 'context': , + 'entity_id': 'sensor.slimme_kattenbak_cat_weight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.slimme_kattenbak_excretion_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.slimme_kattenbak_excretion_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Excretion duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'excretion_time_day', + 'unique_id': 'tuya.5ebss29hqqmse7t5psmexcretion_time_day', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.slimme_kattenbak_excretion_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Slimme kattenbak Excretion duration', + 'state_class': , + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'sensor.slimme_kattenbak_excretion_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.slimme_kattenbak_excretion_times_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.slimme_kattenbak_excretion_times_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Excretion times (day)', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'excretion_times_day', + 'unique_id': 'tuya.5ebss29hqqmse7t5psmexcretion_times_day', + 'unit_of_measurement': 'times', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.slimme_kattenbak_excretion_times_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Slimme kattenbak Excretion times (day)', + 'unit_of_measurement': 'times', + }), + 'context': , + 'entity_id': 'sensor.slimme_kattenbak_excretion_times_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr index 301a9ea8261..74ffb6df720 100644 --- a/tests/components/tuya/snapshots/test_vacuum.ambr +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -28,7 +28,7 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'tuya.mwsaod7fa3gjyh6ids', 'unit_of_measurement': None, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Hoover', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.hoover', diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index 68f80555cd6..7aeaa85649f 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -39,7 +39,7 @@ async def test_setup_entry_fails_config_entry_not_ready( ): config_entry = await config_entry_factory() - assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_entry_fails_trigger_reauth_flow( @@ -56,7 +56,7 @@ async def test_setup_entry_fails_trigger_reauth_flow( config_entry = await config_entry_factory() mock_flow_init.assert_called_once() - assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert config_entry.state is ConfigEntryState.SETUP_ERROR @pytest.mark.parametrize( diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 0c94fb2e20b..97aeec4a00e 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -6,7 +6,6 @@ from collections.abc import Callable, Generator from datetime import datetime, timedelta from functools import partial from ipaddress import IPv4Address -import json from pathlib import Path from tempfile import gettempdir from typing import Any @@ -47,7 +46,7 @@ from homeassistant.util import dt as dt_util from . import _patch_discovery from .utils import MockUFPFixture -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture MAC_ADDR = "aa:bb:cc:dd:ee:ff" @@ -64,7 +63,7 @@ DEFAULT_API_KEY = "test-api-key" def mock_nvr(): """Mock UniFi Protect Camera device.""" - data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_nvr.json", DOMAIN) nvr = NVR.from_unifi_dict(**data) # disable pydantic validation so mocking can happen @@ -91,6 +90,7 @@ def mock_ufp_config_entry(): CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, }, version=2, + unique_id="A1E00C826924", ) @@ -98,7 +98,7 @@ def mock_ufp_config_entry(): def old_nvr(): """Mock UniFi Protect Camera device.""" - data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_nvr.json", DOMAIN) data["version"] = "1.19.0" return NVR.from_unifi_dict(**data) @@ -106,7 +106,7 @@ def old_nvr(): @pytest.fixture(name="bootstrap") def bootstrap_fixture(nvr: NVR): """Mock Bootstrap fixture.""" - data = json.loads(load_fixture("sample_bootstrap.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_bootstrap.json", DOMAIN) data["nvr"] = nvr data["cameras"] = [] data["lights"] = [] @@ -187,7 +187,7 @@ def mock_entry( def liveview(): """Mock UniFi Protect Liveview.""" - data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_liveview.json", DOMAIN) return Liveview.from_unifi_dict(**data) @@ -198,7 +198,7 @@ def camera_fixture(fixed_now: datetime): # disable pydantic validation so mocking can happen Camera.model_config["validate_assignment"] = False - data = json.loads(load_fixture("sample_camera.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_camera.json", DOMAIN) camera = Camera.from_unifi_dict(**data) camera.last_motion = fixed_now - timedelta(hours=1) @@ -229,6 +229,22 @@ def camera_all_fixture(camera: Camera): return all_camera +@pytest.fixture(name="camera_all_features") +def camera_all_features_fixture(fixed_now: datetime): + """Mock UniFi Protect Camera device with all features enabled.""" + + # disable pydantic validation so mocking can happen + Camera.model_config["validate_assignment"] = False + + data = load_json_object_fixture("sample_camera_all_features.json", DOMAIN) + camera = Camera.from_unifi_dict(**data) + camera.last_motion = fixed_now - timedelta(hours=1) + + yield camera + + Camera.model_config["validate_assignment"] = True + + @pytest.fixture(name="doorbell") def doorbell_fixture(camera: Camera, fixed_now: datetime): """Mock UniFi Protect Camera device (with chime).""" @@ -284,7 +300,7 @@ def light_fixture(): # disable pydantic validation so mocking can happen Light.model_config["validate_assignment"] = False - data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_light.json", DOMAIN) yield Light.from_unifi_dict(**data) Light.model_config["validate_assignment"] = True @@ -307,7 +323,7 @@ def viewer(): # disable pydantic validation so mocking can happen Viewer.model_config["validate_assignment"] = False - data = json.loads(load_fixture("sample_viewport.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_viewport.json", DOMAIN) yield Viewer.from_unifi_dict(**data) Viewer.model_config["validate_assignment"] = True @@ -320,7 +336,7 @@ def sensor_fixture(fixed_now: datetime): # disable pydantic validation so mocking can happen Sensor.model_config["validate_assignment"] = False - data = json.loads(load_fixture("sample_sensor.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_sensor.json", DOMAIN) sensor: Sensor = Sensor.from_unifi_dict(**data) sensor.motion_detected_at = fixed_now - timedelta(hours=1) sensor.open_status_changed_at = fixed_now - timedelta(hours=1) @@ -331,7 +347,7 @@ def sensor_fixture(fixed_now: datetime): @pytest.fixture(name="sensor_all") -def csensor_all_fixture(sensor: Sensor): +def sensor_all_fixture(sensor: Sensor): """Mock UniFi Protect Sensor device.""" all_sensor = sensor.model_copy() @@ -352,7 +368,7 @@ def doorlock_fixture(): # disable pydantic validation so mocking can happen Doorlock.model_config["validate_assignment"] = False - data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_doorlock.json", DOMAIN) yield Doorlock.from_unifi_dict(**data) Doorlock.model_config["validate_assignment"] = True @@ -375,7 +391,7 @@ def chime(): # disable pydantic validation so mocking can happen Chime.model_config["validate_assignment"] = False - data = json.loads(load_fixture("sample_chime.json", integration=DOMAIN)) + data = load_json_object_fixture("sample_chime.json", DOMAIN) yield Chime.from_unifi_dict(**data) Chime.model_config["validate_assignment"] = True diff --git a/tests/components/unifiprotect/fixtures/sample_camera_all_features.json b/tests/components/unifiprotect/fixtures/sample_camera_all_features.json new file mode 100644 index 00000000000..c167a50078f --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_camera_all_features.json @@ -0,0 +1,379 @@ +{ + "isDeleting": false, + "mac": "AABBCCDDEEFF", + "host": "192.168.6.91", + "connectionHost": "192.168.178.217", + "type": "UVC G4 Doorbell Pro", + "name": "Test Camera All Features", + "upSince": 1640020678036, + "uptime": 3203, + "lastSeen": 1640023881036, + "connectedSince": 1640020710448, + "state": "CONNECTED", + "hardwareRevision": "11", + "firmwareVersion": "4.47.13", + "latestFirmwareVersion": "4.47.13", + "firmwareBuild": "0a55423.211124.718", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": true, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "lastMotion": 1640021213927, + "micVolume": 50, + "isMicEnabled": true, + "isRecording": false, + "isWirelessUplinkEnabled": true, + "isMotionDetected": false, + "isSmartDetected": false, + "phyRate": 72, + "hdrMode": false, + "videoMode": "default", + "isProbingForWifi": false, + "apMac": null, + "apRssi": null, + "elementInfo": null, + "chimeDuration": 5000, + "chimeType": 1000, + "isDark": false, + "lastPrivacyZonePositionId": null, + "lastRing": 1640021213927, + "isLiveHeatmapEnabled": false, + "anonymousDeviceId": "8833c6e8-fdbb-579d-b496-4fbbfb028c1d", + "eventStats": { + "motion": { + "today": 10, + "average": 39, + "lastDays": [48, 45, 33, 41, 44, 60, 6], + "recentHours": [0, 4, 1, 2, 0, 0, 0, 0, 0, 0, 2, 0, 0] + }, + "smart": { + "today": 0, + "average": 0, + "lastDays": [0, 0, 0, 0, 0, 0, 0] + } + }, + "videoReconfigurationInProgress": false, + "voltage": 20.0, + "wiredConnectionState": { + "phyRate": 1000 + }, + "channels": [ + { + "id": 0, + "videoId": "video1", + "name": "High", + "enabled": true, + "isRtspEnabled": true, + "rtspAlias": "test_high_alias", + "width": 2688, + "height": 1512, + "fps": 30, + "bitrate": 10000000, + "minBitrate": 32000, + "maxBitrate": 10000000, + "minClientAdaptiveBitRate": 0, + "minMotionAdaptiveBitRate": 2000000, + "fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30], + "idrInterval": 5 + }, + { + "id": 1, + "videoId": "video2", + "name": "Medium", + "enabled": true, + "isRtspEnabled": false, + "rtspAlias": null, + "width": 1280, + "height": 720, + "fps": 30, + "bitrate": 1500000, + "minBitrate": 32000, + "maxBitrate": 2000000, + "minClientAdaptiveBitRate": 150000, + "minMotionAdaptiveBitRate": 750000, + "fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30], + "idrInterval": 5 + }, + { + "id": 2, + "videoId": "video3", + "name": "Low", + "enabled": true, + "isRtspEnabled": false, + "rtspAlias": null, + "width": 640, + "height": 360, + "fps": 30, + "bitrate": 200000, + "minBitrate": 32000, + "maxBitrate": 1000000, + "minClientAdaptiveBitRate": 0, + "minMotionAdaptiveBitRate": 200000, + "fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30], + "idrInterval": 5 + } + ], + "ispSettings": { + "aeMode": "auto", + "irLedMode": "custom", + "irLedLevel": 255, + "wdr": 1, + "icrSensitivity": 15, + "brightness": 50, + "contrast": 50, + "hue": 50, + "saturation": 50, + "sharpness": 50, + "denoise": 50, + "isFlippedVertical": false, + "isFlippedHorizontal": false, + "isAutoRotateEnabled": true, + "isLdcEnabled": true, + "is3dnrEnabled": true, + "isExternalIrEnabled": false, + "isAggressiveAntiFlickerEnabled": false, + "isPauseMotionEnabled": false, + "dZoomCenterX": 50, + "dZoomCenterY": 50, + "dZoomScale": 0, + "dZoomStreamId": 4, + "focusMode": "ztrig", + "focusPosition": 0, + "touchFocusX": 1001, + "touchFocusY": 1001, + "zoomPosition": 50, + "mountPosition": "wall" + }, + "talkbackSettings": { + "typeFmt": "aac", + "typeIn": "serverudp", + "bindAddr": "0.0.0.0", + "bindPort": 7004, + "filterAddr": "", + "filterPort": 0, + "channels": 1, + "samplingRate": 22050, + "bitsPerSample": 16, + "quality": 100 + }, + "osdSettings": { + "isNameEnabled": false, + "isDateEnabled": false, + "isLogoEnabled": false, + "isDebugEnabled": false + }, + "ledSettings": { + "isEnabled": false, + "blinkRate": 0 + }, + "speakerSettings": { + "isEnabled": true, + "areSystemSoundsEnabled": true, + "volume": 75, + "speakerVolume": 75, + "ringVolume": 80 + }, + "recordingSettings": { + "prePaddingSecs": 10, + "postPaddingSecs": 10, + "minMotionEventTrigger": 1000, + "endMotionEventDelay": 3000, + "suppressIlluminationSurge": false, + "mode": "always", + "geofencing": "off", + "motionAlgorithm": "enhanced", + "enablePirTimelapse": false, + "useNewMotionAlgorithm": true + }, + "smartDetectSettings": { + "objectTypes": ["person", "vehicle"] + }, + "recordingSchedules": [], + "motionZones": [ + { + "id": 1, + "name": "Default", + "color": "#AB46BC", + "points": [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ], + "sensitivity": 50 + } + ], + "privacyZones": [], + "smartDetectZones": [ + { + "id": 1, + "name": "Default", + "color": "#AB46BC", + "points": [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ], + "sensitivity": 50, + "objectTypes": ["person", "vehicle"] + } + ], + "smartDetectLines": [], + "stats": { + "rxBytes": 100, + "txBytes": 100, + "wifi": { + "channel": 6, + "frequency": 2437, + "linkSpeedMbps": null, + "signalQuality": 100, + "signalStrength": -35 + }, + "battery": { + "percentage": null, + "isCharging": false, + "sleepState": "disconnected" + }, + "video": { + "recordingStart": 1639219284079, + "recordingEnd": 1640021215245, + "recordingStartLQ": 1639219283987, + "recordingEndLQ": 1640021217213, + "timelapseStart": 1639219284030, + "timelapseEnd": 1640023738713, + "timelapseStartLQ": 1639219284030, + "timelapseEndLQ": 1640021765237 + }, + "storage": { + "used": 100, + "rate": 0.1 + }, + "wifiQuality": 100, + "wifiStrength": -35 + }, + "featureFlags": { + "canAdjustIrLedLevel": true, + "canMagicZoom": false, + "canOpticalZoom": true, + "canTouchFocus": true, + "hasAccelerometer": true, + "hasAec": true, + "hasBattery": false, + "hasBluetooth": true, + "hasChime": true, + "hasExternalIr": false, + "hasIcrSensitivity": true, + "hasLdc": true, + "hasLedIr": true, + "hasLedStatus": true, + "hasLineIn": true, + "hasMic": true, + "hasPrivacyMask": true, + "hasRtc": false, + "hasSdCard": false, + "hasSpeaker": true, + "hasWifi": true, + "hasHdr": false, + "hasAutoICROnly": false, + "hasWdr": true, + "isDoorbell": true, + "videoModes": ["default", "highFps"], + "videoModeMaxFps": [30, 60], + "hasMotionZones": true, + "hasLcdScreen": true, + "mountPositions": ["wall", "ceiling"], + "smartDetectTypes": ["person", "vehicle"], + "motionAlgorithms": ["enhanced"], + "hasSquareEventThumbnail": true, + "hasPackageCamera": true, + "privacyMaskCapability": { + "maxMasks": 4, + "rectangleOnly": true + }, + "focus": { + "steps": { + "max": 100, + "min": 0, + "step": 1 + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "pan": { + "steps": { + "max": null, + "min": null, + "step": null + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "tilt": { + "steps": { + "max": null, + "min": null, + "step": null + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "zoom": { + "steps": { + "max": 100, + "min": 0, + "step": 1 + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "hasSmartDetect": true + }, + "pirSettings": { + "pirSensitivity": 100, + "pirMotionClipLength": 15, + "timelapseFrameInterval": 15, + "timelapseTransferInterval": 600 + }, + "lcdMessage": { + "type": "CUSTOM_MESSAGE", + "text": "Welcome", + "resetAt": null + }, + "wifiConnectionState": { + "channel": 6, + "frequency": 2437, + "phyRate": 72, + "signalQuality": 100, + "signalStrength": -50, + "ssid": "Mortis Camera" + }, + "lenses": [], + "id": "1ef173c5g7033e59ae4c423e", + "isConnected": true, + "platform": "sav530q", + "hasSpeaker": true, + "hasWifi": true, + "audioBitrate": 64000, + "canManage": false, + "isManaged": true, + "marketName": "G4 Doorbell Pro", + "modelKey": "camera" +} diff --git a/tests/components/unifiprotect/snapshots/test_diagnostics.ambr b/tests/components/unifiprotect/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..293c17fd7be --- /dev/null +++ b/tests/components/unifiprotect/snapshots/test_diagnostics.ambr @@ -0,0 +1,985 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'bootstrap': dict({ + 'accessKey': '**REDACTED**', + 'aiports': list([ + ]), + 'authUserId': '**REDACTED_ID**', + 'bridges': list([ + dict({ + 'anonymousDeviceId': None, + 'bridge': None, + 'canAdopt': False, + 'connectedSince': 1642374159304, + 'connectionHost': '**REDACTED_IP**', + 'firmwareBuild': None, + 'firmwareVersion': '0.3.1', + 'fwUpdateState': None, + 'guid': None, + 'hardwareRevision': '19', + 'host': '**REDACTED_IP**', + 'id': '**REDACTED_ID**', + 'isAdopted': True, + 'isAdoptedByOther': False, + 'isAdopting': False, + 'isAttemptingToConnect': False, + 'isConnected': True, + 'isDownloadingFW': None, + 'isProvisioned': False, + 'isRebooting': False, + 'isRestoring': None, + 'isSshEnabled': False, + 'isUpdating': False, + 'lastDisconnect': None, + 'lastSeen': 1643055759891, + 'latestFirmwareVersion': None, + 'mac': '**REDACTED_MAC**', + 'marketName': None, + 'modelKey': 'bridge', + 'name': '**REDACTED_NAME**', + 'nvrMac': None, + 'platform': 'mt7621', + 'state': 'CONNECTED', + 'type': 'UFP-UAP-B', + 'upSince': 1639807977891, + 'uptime': 3247782, + 'wiredConnectionState': dict({ + 'phyRate': None, + }), + }), + dict({ + 'anonymousDeviceId': None, + 'bridge': None, + 'canAdopt': False, + 'connectedSince': 1643052754695, + 'connectionHost': '**REDACTED_IP**', + 'firmwareBuild': None, + 'firmwareVersion': '0.3.1', + 'fwUpdateState': None, + 'guid': None, + 'hardwareRevision': '19', + 'host': '**REDACTED_IP**', + 'id': '**REDACTED_ID**', + 'isAdopted': True, + 'isAdoptedByOther': False, + 'isAdopting': False, + 'isAttemptingToConnect': False, + 'isConnected': True, + 'isDownloadingFW': None, + 'isProvisioned': False, + 'isRebooting': False, + 'isRestoring': None, + 'isSshEnabled': False, + 'isUpdating': False, + 'lastDisconnect': None, + 'lastSeen': 1643052750862, + 'latestFirmwareVersion': None, + 'mac': '**REDACTED_MAC**', + 'marketName': None, + 'modelKey': 'bridge', + 'name': '**REDACTED_NAME**', + 'nvrMac': None, + 'platform': 'mt7621', + 'state': 'CONNECTED', + 'type': 'UFP-UAP-B', + 'upSince': 1641257260772, + 'uptime': None, + 'wiredConnectionState': dict({ + 'phyRate': None, + }), + }), + ]), + 'cameras': list([ + ]), + 'chimes': list([ + ]), + 'doorlocks': list([ + ]), + 'groups': list([ + dict({ + 'id': '**REDACTED_ID**', + 'isDefault': True, + 'modelKey': 'group', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'nvr:write,delete:*', + 'group:create,read,write,delete:*', + 'user:create,read,write,delete:*', + 'schedule:create,read,write,delete:*', + 'legacyUFV:read,write,delete:*', + 'bridge:create,read,write,delete:*', + 'camera:create,read,write,delete,readmedia,readlive,deletemedia:*', + 'light:create,read,write,delete:*', + 'sensor:create,read,write,delete:*', + 'doorlock:create,read,write,delete:*', + 'viewer:create,read,write,delete:*', + 'display:create,read,write,delete:*', + 'chime:create,read,write,delete:*', + ]), + 'type': 'preset', + }), + dict({ + 'id': '**REDACTED_ID**', + 'isDefault': False, + 'modelKey': 'group', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'bridge:read:*', + 'camera:read,readmedia,readlive:*', + 'doorlock:read:*', + 'light:read:*', + 'sensor:read:*', + 'viewer:read:*', + 'display:read:*', + 'chime:read:*', + ]), + 'type': 'preset', + }), + ]), + 'keyrings': dict({ + '__type': "", + }), + 'lastUpdateId': '**REDACTED_UUID**', + 'lights': list([ + dict({ + 'anonymousDeviceId': None, + 'bridge': None, + 'camera': None, + 'canAdopt': False, + 'connectedSince': 1640020579711, + 'connectionHost': '**REDACTED_IP**', + 'firmwareBuild': 'g990c553.211105.251', + 'firmwareVersion': '1.9.3', + 'fwUpdateState': None, + 'guid': None, + 'hardwareRevision': None, + 'host': '**REDACTED_IP**', + 'id': '**REDACTED_ID**', + 'isAdopted': True, + 'isAdoptedByOther': False, + 'isAdopting': False, + 'isAttemptingToConnect': False, + 'isCameraPaired': True, + 'isConnected': True, + 'isDark': False, + 'isDownloadingFW': None, + 'isLightOn': False, + 'isLocating': False, + 'isPirMotionDetected': False, + 'isProvisioned': False, + 'isRebooting': False, + 'isRestoring': None, + 'isSshEnabled': False, + 'isUpdating': False, + 'lastDisconnect': None, + 'lastMotion': 1640022006069, + 'lastSeen': 1640023881022, + 'latestFirmwareVersion': '1.9.3', + 'lightDeviceSettings': dict({ + 'isIndicatorEnabled': False, + 'ledLevel': 6, + 'luxSensitivity': 'medium', + 'pirDuration': 45000, + 'pirSensitivity': 45, + }), + 'lightModeSettings': dict({ + 'enableAt': 'fulltime', + 'mode': 'motion', + }), + 'lightOnSettings': dict({ + 'isLedForceOn': False, + }), + 'mac': '**REDACTED_MAC**', + 'marketName': 'UP FloodLight', + 'modelKey': 'light', + 'name': '**REDACTED_NAME**', + 'nvrMac': None, + 'state': 'CONNECTED', + 'type': 'UP FloodLight', + 'upSince': 1638128991022, + 'uptime': 1894890, + 'wiredConnectionState': dict({ + 'phyRate': 100, + }), + }), + ]), + 'liveviews': list([ + ]), + 'nvr': dict({ + 'analyticsData': 'anonymous', + 'anonymousDeviceId': '**REDACTED_UUID**', + 'cameraUtilization': 30, + 'canAutoUpdate': True, + 'corruptionState': None, + 'countryCode': None, + 'disableAudio': False, + 'disableAutoLink': False, + 'doorbellSettings': dict({ + 'allMessages': list([ + dict({ + 'text': 'LEAVE PACKAGE AT DOOR', + 'type': 'LEAVE_PACKAGE_AT_DOOR', + }), + dict({ + 'text': 'DO NOT DISTURB', + 'type': 'DO_NOT_DISTURB', + }), + dict({ + 'text': 'Test', + 'type': 'CUSTOM_MESSAGE', + }), + ]), + 'customMessages': list([ + 'Come In!', + 'Use Other Door', + ]), + 'defaultMessageResetTimeoutMs': 60000, + 'defaultMessageText': 'Welcome', + }), + 'enableAutomaticBackups': True, + 'enableBridgeAutoAdoption': True, + 'enableCrashReporting': True, + 'enableStatsReporting': False, + 'featureFlags': dict({ + 'beta': False, + 'detectionLabels': None, + 'dev': False, + 'hasTwoWayAudioMediaStreams': None, + 'homekitPaired': None, + 'notificationsV2': True, + 'ulpRoleManagement': None, + }), + 'firmwareVersion': '2.3.10', + 'globalCameraSettings': None, + 'hardDriveState': None, + 'hardwareId': '**REDACTED_UUID**', + 'hardwarePlatform': 'al324', + 'hardwareRevision': '113-03137-22', + 'hasGateway': None, + 'host': '**REDACTED_IP**', + 'hostShortname': 'UNVRPRO', + 'hostType': 59936, + 'hosts': list([ + '**REDACTED_IP**', + ]), + 'id': '**REDACTED_ID**', + 'isAway': True, + 'isDbAvailable': None, + 'isHardware': True, + 'isInsightsEnabled': True, + 'isNetworkInstalled': None, + 'isPrimary': None, + 'isProtectUpdatable': None, + 'isRecordingDisabled': False, + 'isRecordingMotionOnly': False, + 'isRecycling': False, + 'isSetup': True, + 'isSshEnabled': False, + 'isStacked': None, + 'isStation': False, + 'isStatsGatheringEnabled': True, + 'isUCoreSetup': None, + 'isUCoreStacked': None, + 'isUcoreUpdatable': None, + 'isUpdating': False, + 'isVaultRegistered': None, + 'isWirelessUplinkEnabled': False, + 'lastDeviceFWUpdatesCheckedAt': None, + 'lastDriveSlowEvent': None, + 'lastSeen': 1641269019283, + 'lastUpdateAt': None, + 'locationSettings': dict({ + 'isAway': True, + 'isGeofencingEnabled': False, + 'latitude': 41.4519, + 'longitude': -81.921, + 'radius': 200, + }), + 'mac': '**REDACTED_MAC**', + 'marketName': None, + 'maxCameraCapacity': dict({ + '2K': 30, + '4K': 20, + 'HD': 60, + }), + 'modelKey': 'nvr', + 'name': '**REDACTED_NAME**', + 'network': 'Ethernet', + 'ports': dict({ + 'aiFeatureConsole': None, + 'cameraEvents': 7551, + 'cameraHttps': 7444, + 'devicesWss': 7442, + 'discoveryClient': 0, + 'emsCLI': 7440, + 'emsJsonCLI': None, + 'emsLiveFLV': 7550, + 'http': 7080, + 'https': 7443, + 'liveWs': 7445, + 'liveWss': 7446, + 'piongw': None, + 'playback': 7450, + 'rtmp': 1935, + 'rtsp': 7447, + 'rtsps': 7441, + 'stacking': None, + 'tcpBridge': 7888, + 'tcpStreams': 7448, + 'ucore': 11081, + 'ump': 7449, + }), + 'publicIp': None, + 'recordingRetentionDurationMs': None, + 'releaseChannel': 'release', + 'skipFirmwareUpdate': False, + 'smartDetection': None, + 'ssoChannel': None, + 'storageStats': dict({ + 'capacity': 5706909122, + 'recordingSpace': dict({ + 'available': 23327455092736, + 'total': 31787269955584, + 'used': 8459814862848, + }), + 'remainingCapacity': 4188081155, + 'storageDistribution': dict({ + 'recordingTypeDistributions': list([ + dict({ + 'percentage': 91.47686438351941, + 'recordingType': 'rotating', + 'size': 7736989099040, + }), + dict({ + 'percentage': 0.2539037704709915, + 'recordingType': 'timelapse', + 'size': 21474836480, + }), + dict({ + 'percentage': 8.269231846009593, + 'recordingType': 'detections', + 'size': 699400412128, + }), + ]), + 'resolutionDistributions': list([ + dict({ + 'percentage': 9.113571077981481, + 'resolution': 'HD', + 'size': 2896955441152, + }), + dict({ + 'percentage': 17.494138107066746, + 'resolution': '4K', + 'size': 5560908906496, + }), + dict({ + 'percentage': 73.39229081495176, + 'resolution': 'free', + 'size': 23329405607936, + }), + ]), + }), + 'utilization': 26.61384533704469, + }), + 'streamSharingAvailable': None, + 'systemInfo': dict({ + 'cpu': dict({ + 'averageLoad': 5, + 'temperature': 70, + }), + 'memory': dict({ + 'available': 6481504, + 'free': 87080, + 'total': 8163024, + }), + 'storage': dict({ + 'available': 21796939214848, + 'capability': None, + 'devices': list([ + dict({ + 'healthy': True, + 'model': 'ST16000VE000-2L2103', + 'size': 16000900661248, + }), + dict({ + 'healthy': True, + 'model': 'ST16000VE000-2L2103', + 'size': 16000900661248, + }), + dict({ + 'healthy': True, + 'model': 'ST16000VE000-2L2103', + 'size': 16000900661248, + }), + ]), + 'isRecycling': False, + 'size': 31855989432320, + 'type': 'raid', + 'used': 8459815895040, + }), + 'tmpfs': dict({ + 'available': 934204, + 'path': '/var/opt/unifi-protect/tmp', + 'total': 1048576, + 'used': 114372, + }), + 'ustorage': dict({ + 'disks': list([ + dict({ + 'action': 'expanding', + 'ata': 'ACS-4', + 'bad_sector': 0, + 'estimate': 234395.733, + 'firmware': 'EV02', + 'healthy': 'good', + 'life_span': None, + 'model': 'ST16000VE000-2L2103', + 'poweronhrs': 4242, + 'progress': 21.390607518939174, + 'reason': None, + 'rpm': 7200, + 'sata': 'SATA 3.3', + 'serial': 'ABCD1234', + 'size': None, + 'slot': 1, + 'state': 'expanding', + 'temperature': 52, + 'threshold': 10, + 'type': 'HDD', + }), + dict({ + 'action': 'expanding', + 'ata': 'ACS-4', + 'bad_sector': 0, + 'estimate': 234395.733, + 'firmware': 'EV02', + 'healthy': 'good', + 'life_span': None, + 'model': 'ST16000VE000-2L2103', + 'poweronhrs': 4242, + 'progress': 21.390607518939174, + 'reason': None, + 'rpm': 7200, + 'sata': 'SATA 3.3', + 'serial': 'ABCD1234', + 'size': None, + 'slot': 2, + 'state': 'expanding', + 'temperature': 52, + 'threshold': 10, + 'type': 'HDD', + }), + dict({ + 'action': 'expanding', + 'ata': 'ACS-4', + 'bad_sector': 0, + 'estimate': 234395.733, + 'firmware': 'EV02', + 'healthy': 'good', + 'life_span': None, + 'model': 'ST16000VE000-2L2103', + 'poweronhrs': 4242, + 'progress': 21.390607518939174, + 'reason': None, + 'rpm': 7200, + 'sata': 'SATA 3.3', + 'serial': 'ABCD1234', + 'size': None, + 'slot': 3, + 'state': 'expanding', + 'temperature': 51, + 'threshold': 10, + 'type': 'HDD', + }), + dict({ + 'action': 'expanding', + 'ata': 'ACS-4', + 'bad_sector': 0, + 'estimate': 234395.733, + 'firmware': 'EV02', + 'healthy': 'good', + 'life_span': None, + 'model': 'ST16000VE000-2L2103', + 'poweronhrs': 2443, + 'progress': 21.390607518939174, + 'reason': None, + 'rpm': 7200, + 'sata': 'SATA 3.3', + 'serial': 'ABCD1234', + 'size': None, + 'slot': 4, + 'state': 'expanding', + 'temperature': 50, + 'threshold': 10, + 'type': 'HDD', + }), + dict({ + 'action': 'expanding', + 'ata': 'ACS-4', + 'bad_sector': 0, + 'estimate': 234395.733, + 'firmware': 'EV02', + 'healthy': 'good', + 'life_span': None, + 'model': 'ST16000VE000-2L2103', + 'poweronhrs': 783, + 'progress': 21.390607518939174, + 'reason': None, + 'rpm': 7200, + 'sata': 'SATA 3.3', + 'serial': 'ABCD1234', + 'size': None, + 'slot': 5, + 'state': 'expanding', + 'temperature': 50, + 'threshold': 10, + 'type': 'HDD', + }), + dict({ + 'size': None, + 'slot': 6, + 'state': 'nodisk', + }), + dict({ + 'action': 'expanding', + 'ata': 'ACS-4', + 'bad_sector': 0, + 'estimate': 234395.733, + 'firmware': 'EV01', + 'healthy': 'good', + 'life_span': None, + 'model': 'ST16000VE002-3BR101', + 'poweronhrs': 18, + 'progress': 21.390607518939174, + 'reason': None, + 'rpm': 7200, + 'sata': 'SATA 3.3', + 'serial': 'ABCD1234', + 'size': None, + 'slot': 7, + 'state': 'expanding', + 'temperature': 45, + 'threshold': 10, + 'type': 'HDD', + }), + ]), + 'space': list([ + dict({ + 'action': 'expanding', + 'device': 'md3', + 'estimate': 234395.733, + 'health': None, + 'progress': 21.390607518939174, + 'space_type': None, + 'total_bytes': 63713403555840, + 'used_bytes': 57006577086464, + }), + dict({ + 'action': 'syncing', + 'device': 'md0', + 'estimate': None, + 'health': None, + 'progress': 0, + 'space_type': None, + 'total_bytes': 0, + 'used_bytes': 0, + }), + ]), + }), + }), + 'temperatureUnit': 'C', + 'timeFormat': '24h', + 'timezone': 'America/New_York', + 'type': 'UNVR-PRO', + 'ucoreVersion': '2.3.26', + 'uiVersion': None, + 'ulpVersion': None, + 'upSince': 1640077503063, + 'uptime': 1191516000, + 'vaultCameras': list([ + ]), + 'version': '6.0.0', + 'wanIp': None, + }), + 'ringtones': list([ + dict({ + 'id': '**REDACTED_ID**', + 'isDefault': True, + 'modelKey': 'ringtone', + 'name': '**REDACTED_NAME**', + 'nvrMac': '**REDACTED_MAC**', + 'size': 208, + }), + dict({ + 'id': '**REDACTED_ID**', + 'isDefault': False, + 'modelKey': 'ringtone', + 'name': '**REDACTED_NAME**', + 'nvrMac': '**REDACTED_MAC**', + 'size': 180, + }), + ]), + 'sensors': list([ + ]), + 'ulpUsers': dict({ + '__type': "", + }), + 'users': list([ + dict({ + 'allPermissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'nvr:write,delete:*', + 'group:create,read,write,delete:*', + 'user:create,read,write,delete:*', + 'schedule:create,read,write,delete:*', + 'legacyUFV:read,write,delete:*', + 'bridge:create,read,write,delete:*', + 'camera:create,read,write,delete,readmedia,readlive,deletemedia:*', + 'light:create,read,write,delete:*', + 'sensor:create,read,write,delete:*', + 'doorlock:create,read,write,delete:*', + 'viewer:create,read,write,delete:*', + 'display:create,read,write,delete:*', + 'chime:create,read,write,delete:*', + ]), + 'cloudAccount': dict({ + 'cloudId': '**REDACTED_UUID**', + 'email': '**REDACTED**@example.com', + 'firstName': '**REDACTED_NAME**', + 'id': '**REDACTED_UUID**', + 'lastName': '**REDACTED_NAME**', + 'modelKey': 'cloudIdentity', + 'name': '**REDACTED_NAME**', + 'profileImg': None, + 'user': '**REDACTED_ID**', + }), + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': True, + 'id': '**REDACTED_ID**', + 'isOwner': True, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + ]), + 'scopes': None, + }), + dict({ + 'allPermissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'bridge:read:*', + 'camera:read,readmedia,readlive:*', + 'doorlock:read:*', + 'light:read:*', + 'sensor:read:*', + 'viewer:read:*', + 'display:read:*', + 'chime:read:*', + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'nvr:write,delete:*', + 'group:create,read,write,delete:*', + 'user:create,read,write,delete:*', + 'schedule:create,read,write,delete:*', + 'legacyUFV:read,write,delete:*', + 'bridge:create,read,write,delete:*', + 'camera:create,read,write,delete,readmedia,readlive,deletemedia:*', + 'light:create,read,write,delete:*', + 'sensor:create,read,write,delete:*', + 'doorlock:create,read,write,delete:*', + 'viewer:create,read,write,delete:*', + 'display:create,read,write,delete:*', + 'chime:create,read,write,delete:*', + ]), + 'cloudAccount': None, + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': False, + 'id': '**REDACTED_ID**', + 'isOwner': False, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + ]), + 'scopes': None, + }), + dict({ + 'allPermissions': list([ + 'liveview:*:**REDACTED_ID**', + 'liveview:*:**REDACTED_ID**', + 'liveview:*:**REDACTED_ID**', + 'liveview:*:**REDACTED_ID**', + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'nvr:write,delete:*', + 'group:create,read,write,delete:*', + 'user:create,read,write,delete:*', + 'schedule:create,read,write,delete:*', + 'legacyUFV:read,write,delete:*', + 'bridge:create,read,write,delete:*', + 'camera:create,read,write,delete,readmedia,readlive,deletemedia:*', + 'light:create,read,write,delete:*', + 'sensor:create,read,write,delete:*', + 'doorlock:create,read,write,delete:*', + 'viewer:create,read,write,delete:*', + 'display:create,read,write,delete:*', + 'chime:create,read,write,delete:*', + ]), + 'cloudAccount': None, + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': False, + 'id': '**REDACTED_ID**', + 'isOwner': False, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'location': dict({ + 'isAway': True, + 'latitude': None, + 'longitude': None, + }), + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + 'liveview:*:**REDACTED_ID**', + 'liveview:*:**REDACTED_ID**', + 'liveview:*:**REDACTED_ID**', + 'liveview:*:**REDACTED_ID**', + ]), + 'scopes': None, + }), + dict({ + 'allPermissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'bridge:read:*', + 'camera:read,readmedia,readlive:*', + 'doorlock:read:*', + 'light:read:*', + 'sensor:read:*', + 'viewer:read:*', + 'display:read:*', + 'chime:read:*', + ]), + 'cloudAccount': None, + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': False, + 'id': '**REDACTED_ID**', + 'isOwner': False, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + ]), + 'scopes': None, + }), + dict({ + 'allPermissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'bridge:read:*', + 'camera:read,readmedia,readlive:*', + 'doorlock:read:*', + 'light:read:*', + 'sensor:read:*', + 'viewer:read:*', + 'display:read:*', + 'chime:read:*', + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'nvr:write,delete:*', + 'group:create,read,write,delete:*', + 'user:create,read,write,delete:*', + 'schedule:create,read,write,delete:*', + 'legacyUFV:read,write,delete:*', + 'bridge:create,read,write,delete:*', + 'camera:create,read,write,delete,readmedia,readlive,deletemedia:*', + 'light:create,read,write,delete:*', + 'sensor:create,read,write,delete:*', + 'doorlock:create,read,write,delete:*', + 'viewer:create,read,write,delete:*', + 'display:create,read,write,delete:*', + 'chime:create,read,write,delete:*', + ]), + 'cloudAccount': None, + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': False, + 'id': '**REDACTED_ID**', + 'isOwner': False, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + ]), + 'scopes': None, + }), + dict({ + 'allPermissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'bridge:read:*', + 'camera:read,readmedia,readlive:*', + 'doorlock:read:*', + 'light:read:*', + 'sensor:read:*', + 'viewer:read:*', + 'display:read:*', + 'chime:read:*', + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'nvr:write,delete:*', + 'group:create,read,write,delete:*', + 'user:create,read,write,delete:*', + 'schedule:create,read,write,delete:*', + 'legacyUFV:read,write,delete:*', + 'bridge:create,read,write,delete:*', + 'camera:create,read,write,delete,readmedia,readlive,deletemedia:*', + 'light:create,read,write,delete:*', + 'sensor:create,read,write,delete:*', + 'doorlock:create,read,write,delete:*', + 'viewer:create,read,write,delete:*', + 'display:create,read,write,delete:*', + 'chime:create,read,write,delete:*', + ]), + 'cloudAccount': None, + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': False, + 'id': '**REDACTED_ID**', + 'isOwner': False, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + ]), + 'scopes': None, + }), + dict({ + 'allPermissions': list([ + 'nvr:read:*', + 'liveview:create', + 'user:read,write,delete:$', + 'bridge:read:*', + 'camera:read,readmedia,readlive:*', + 'doorlock:read:*', + 'light:read:*', + 'sensor:read:*', + 'viewer:read:*', + 'display:read:*', + 'chime:read:*', + ]), + 'cloudAccount': None, + 'email': '**REDACTED**@example.com', + 'enableNotifications': False, + 'featureFlags': dict({ + 'notificationsV2': True, + }), + 'firstName': '**REDACTED_NAME**', + 'groups': list([ + '**REDACTED_ID**', + ]), + 'hasAcceptedInvite': False, + 'id': '**REDACTED_ID**', + 'isOwner': False, + 'lastLoginIp': None, + 'lastLoginTime': None, + 'lastName': '**REDACTED_NAME**', + 'localUsername': '**REDACTED_NAME**', + 'modelKey': 'user', + 'name': '**REDACTED_NAME**', + 'permissions': list([ + ]), + 'scopes': None, + }), + ]), + 'viewers': list([ + ]), + }), + 'options': dict({ + }), + }) +# --- diff --git a/tests/components/unifiprotect/snapshots/test_init.ambr b/tests/components/unifiprotect/snapshots/test_init.ambr index 49c113e856c..e53b25c4ad3 100644 --- a/tests/components/unifiprotect/snapshots/test_init.ambr +++ b/tests/components/unifiprotect/snapshots/test_init.ambr @@ -25,7 +25,7 @@ }), 'manufacturer': 'Ubiquiti', 'model': 'UNVR-PRO', - 'model_id': None, + 'model_id': 'UNVR-PRO', 'name': 'UnifiProtect', 'name_by_user': None, 'primary_config_entry': , diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index bcd3e89b784..2257b256006 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -2,8 +2,9 @@ from __future__ import annotations -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch +import pytest from uiprotect.data.devices import Camera, Chime, Doorlock from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -34,67 +35,46 @@ async def test_button_chime_remove( assert_entity_counts(hass, Platform.BUTTON, 4, 2) -async def test_reboot_button( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - ufp: MockUFPFixture, - chime: Chime, -) -> None: - """Test button entity.""" - - await init_entry(hass, ufp, [chime]) - assert_entity_counts(hass, Platform.BUTTON, 4, 2) - - ufp.api.reboot_device = AsyncMock() - - unique_id = f"{chime.mac}_reboot" - entity_id = "button.test_chime_restart" - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.disabled - assert entity.unique_id == unique_id - - await enable_entity(hass, ufp.entry.entry_id, entity_id) - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - await hass.services.async_call( - "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - ufp.api.reboot_device.assert_called_once() - - +@pytest.mark.parametrize( + ("unique_id_suffix", "entity_id", "api_method", "is_disabled"), + [ + ("reboot", "button.test_chime_restart", "reboot_device", True), + ("play", "button.test_chime_play_chime", "play_speaker", False), + ], +) async def test_chime_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, ufp: MockUFPFixture, chime: Chime, + unique_id_suffix: str, + entity_id: str, + api_method: str, + is_disabled: bool, ) -> None: - """Test button entity.""" - + """Test chime button entities.""" await init_entry(hass, ufp, [chime]) assert_entity_counts(hass, Platform.BUTTON, 4, 2) - ufp.api.play_speaker = AsyncMock() - - unique_id = f"{chime.mac}_play" - entity_id = "button.test_chime_play_chime" + unique_id = f"{chime.mac}_{unique_id_suffix}" entity = entity_registry.async_get(entity_id) assert entity - assert not entity.disabled + assert entity.disabled is is_disabled assert entity.unique_id == unique_id + if is_disabled: + await enable_entity(hass, ufp.entry.entry_id, entity_id) + state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - await hass.services.async_call( - "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - ufp.api.play_speaker.assert_called_once() + with patch.object(ufp.api, api_method, AsyncMock()) as mock_api_method: + await hass.services.async_call( + "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_api_method.assert_called_once() async def test_adopt_button( diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 8541895e9b3..717f2c3a392 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -596,11 +596,21 @@ async def test_camera_ws_update_offline( assert state and state.state == "idle" -async def test_camera_enable_motion( - hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +@pytest.mark.parametrize( + ("service", "expected_value"), + [ + ("enable_motion_detection", True), + ("disable_motion_detection", False), + ], +) +async def test_camera_motion_detection( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + service: str, + expected_value: bool, ) -> None: - """Tests generic entity update service.""" - + """Test enabling/disabling motion detection on camera.""" await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) entity_id = "camera.test_camera_high_resolution_channel" @@ -610,31 +620,9 @@ async def test_camera_enable_motion( await hass.services.async_call( "camera", - "enable_motion_detection", + service, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - camera.set_motion_detection.assert_called_once_with(True) - - -async def test_camera_disable_motion( - hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera -) -> None: - """Tests generic entity update service.""" - - await init_entry(hass, ufp, [camera]) - assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high_resolution_channel" - - camera.__pydantic_fields__["set_motion_detection"] = Mock(final=False, frozen=False) - camera.set_motion_detection = AsyncMock() - - await hass.services.async_call( - "camera", - "disable_motion_detection", - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - camera.set_motion_detection.assert_called_once_with(False) + camera.set_motion_detection.assert_called_once_with(expected_value) diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 105c6213074..9301839b69f 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -118,8 +118,8 @@ async def _complete_reconfigure_flow( return result -async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None: - """Test we get the form.""" +async def test_user_flow(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None: + """Test successful user flow creates config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -145,7 +145,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None return_value=True, ) as mock_setup, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -156,9 +156,9 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "UnifiProtect" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "UnifiProtect" + assert result["data"] == { "host": "1.1.1.1", "username": "test-username", "password": "test-password", @@ -167,14 +167,15 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "port": 443, "verify_ssl": False, } + assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1 async def test_form_version_too_old( - hass: HomeAssistant, bootstrap: Bootstrap, old_nvr: NVR + hass: HomeAssistant, bootstrap: Bootstrap, old_nvr: NVR, nvr: NVR, mock_setup: None ) -> None: - """Test we handle the version being too old.""" + """Test we handle the version being too old and can recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -190,7 +191,7 @@ async def test_form_version_too_old( return_value=None, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -200,12 +201,39 @@ async def test_form_version_too_old( }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "protect_version"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "protect_version"} + + # Now test recovery with valid version + bootstrap.nvr = nvr + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": DEFAULT_HOST, + "username": DEFAULT_USERNAME, + "password": DEFAULT_PASSWORD, + "api_key": DEFAULT_API_KEY, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac) -async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: - """Test we handle invalid auth password.""" +async def test_form_invalid_auth_password( + hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR, mock_setup: None +) -> None: + """Test we handle invalid auth password and can recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -220,7 +248,7 @@ async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: return_value=None, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -230,14 +258,39 @@ async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + + # Now test recovery with valid credentials + bootstrap.nvr = nvr + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": DEFAULT_HOST, + "username": DEFAULT_USERNAME, + "password": "correct-password", + "api_key": DEFAULT_API_KEY, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac) async def test_form_invalid_auth_api_key( - hass: HomeAssistant, bootstrap: Bootstrap + hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR, mock_setup: None ) -> None: - """Test we handle invalid auth api key.""" + """Test we handle invalid auth api key and can recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -252,7 +305,7 @@ async def test_form_invalid_auth_api_key( side_effect=NotAuthorized, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -262,14 +315,43 @@ async def test_form_invalid_auth_api_key( }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"api_key": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"api_key": "invalid_auth"} + + # Now test recovery with valid API key + bootstrap.nvr = nvr + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": DEFAULT_HOST, + "username": DEFAULT_USERNAME, + "password": DEFAULT_PASSWORD, + "api_key": "correct-api-key", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac) async def test_form_cloud_user( - hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount + hass: HomeAssistant, + bootstrap: Bootstrap, + cloud_account: CloudAccount, + nvr: NVR, + mock_setup: None, ) -> None: - """Test we handle cloud users.""" + """Test we handle cloud users and can recover with local user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -287,7 +369,7 @@ async def test_form_cloud_user( return_value=None, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -297,12 +379,41 @@ async def test_form_cloud_user( }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cloud_user"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cloud_user"} + + # Now test recovery with local user + user.cloud_account = None + bootstrap.users[bootstrap.auth_user_id] = user + bootstrap.nvr = nvr + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": DEFAULT_HOST, + "username": "local-username", + "password": DEFAULT_PASSWORD, + "api_key": DEFAULT_API_KEY, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac) -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" +async def test_form_cannot_connect( + hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR, mock_setup: None +) -> None: + """Test we handle cannot connect error and can recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -317,7 +428,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: side_effect=NvrError, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -327,8 +438,33 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Now test recovery when connection works + bootstrap.nvr = nvr + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": DEFAULT_HOST, + "username": DEFAULT_USERNAME, + "password": DEFAULT_PASSWORD, + "api_key": DEFAULT_API_KEY, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac) async def test_form_reauth_auth( @@ -364,7 +500,7 @@ async def test_form_reauth_auth( return_value=None, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -373,9 +509,9 @@ async def test_form_reauth_auth( }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} - assert result2["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + assert result["step_id"] == "reauth_confirm" bootstrap.nvr = nvr with ( @@ -392,18 +528,17 @@ async def test_form_reauth_auth( return_value=None, ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { "username": "test-username", "password": "new-password", "api_key": "test-api-key", }, ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert len(mock_setup.mock_calls) == 1 # Verify that non-sensitive data was preserved when only credentials were updated @@ -415,24 +550,13 @@ async def test_form_reauth_auth( assert ufp_reauth_entry.data[CONF_API_KEY] == "test-api-key" -async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -> None: +async def test_form_options( + hass: HomeAssistant, + ufp_config_entry: MockConfigEntry, + ufp_client: ProtectApiClient, +) -> None: """Test we handle options flows.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - "api_key": "test-api-key", - "id": "UnifiProtect", - "port": 443, - "verify_ssl": False, - "max_media": 1000, - }, - version=2, - unique_id=_async_unifi_mac_from_hass(MAC_ADDR), - ) - mock_config.add_to_hass(hass) + ufp_config_entry.add_to_hass(hass) with ( _patch_discovery(), @@ -443,16 +567,16 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - ): mock_api.return_value = ufp_client - await hass.config_entries.async_setup(mock_config.entry_id) + await hass.config_entries.async_setup(ufp_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config.state is ConfigEntryState.LOADED + assert ufp_config_entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_init(mock_config.entry_id) + result = await hass.config_entries.options.async_init(ufp_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert not result["errors"] assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], { CONF_DISABLE_RTSP: True, @@ -461,15 +585,15 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { "all_updates": True, "disable_rtsp": True, "override_connection_host": True, "max_media": 1000, } await hass.async_block_till_done() - await hass.config_entries.async_unload(mock_config.entry_id) + await hass.config_entries.async_unload(ufp_config_entry.entry_id) @pytest.mark.parametrize( @@ -538,7 +662,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( return_value=True, ) as mock_setup, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -548,9 +672,9 @@ async def test_discovered_by_unifi_discovery_direct_connect( ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "UnifiProtect" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "UnifiProtect" + assert result["data"] == { "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", @@ -559,6 +683,9 @@ async def test_discovered_by_unifi_discovery_direct_connect( "port": 443, "verify_ssl": True, } + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1 @@ -570,13 +697,13 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( mock_config = MockConfigEntry( domain=DOMAIN, data={ - "host": "y.ui.direct", - "username": "test-username", - "password": "test-password", - "api_key": "test-api-key", + CONF_HOST: "y.ui.direct", + CONF_USERNAME: DEFAULT_USERNAME, + CONF_PASSWORD: DEFAULT_PASSWORD, + CONF_API_KEY: DEFAULT_API_KEY, "id": "UnifiProtect", - "port": 443, - "verify_ssl": True, + CONF_PORT: DEFAULT_PORT, + CONF_VERIFY_SSL: True, }, version=2, unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(), @@ -745,7 +872,7 @@ async def test_discovered_by_unifi_discovery( return_value=True, ) as mock_setup, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -755,9 +882,9 @@ async def test_discovered_by_unifi_discovery( ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "UnifiProtect" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "UnifiProtect" + assert result["data"] == { "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", @@ -766,6 +893,9 @@ async def test_discovered_by_unifi_discovery( "port": 443, "verify_ssl": False, } + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1 @@ -812,7 +942,7 @@ async def test_discovered_by_unifi_discovery_partial( return_value=True, ) as mock_setup, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -822,9 +952,9 @@ async def test_discovered_by_unifi_discovery_partial( ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "UnifiProtect" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "UnifiProtect" + assert result["data"] == { "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", @@ -833,6 +963,9 @@ async def test_discovered_by_unifi_discovery_partial( "port": 443, "verify_ssl": False, } + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1 @@ -1005,7 +1138,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa return_value=True, ) as mock_setup, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -1015,9 +1148,9 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "UnifiProtect" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "UnifiProtect" + assert result["data"] == { "host": "nomatchsameip.ui.direct", "username": "test-username", "password": "test-password", @@ -1026,6 +1159,9 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "port": 443, "verify_ssl": True, } + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) assert len(mock_setup_entry.mock_calls) == 2 assert len(mock_setup.mock_calls) == 1 @@ -1088,8 +1224,10 @@ async def test_discovery_can_be_ignored(hass: HomeAssistant) -> None: async def test_discovery_with_both_ignored_and_normal_entry( hass: HomeAssistant, + bootstrap: Bootstrap, + nvr: NVR, ) -> None: - """Test discovery skips ignored entries with different MAC.""" + """Test discovery skips ignored entries with different MAC and completes.""" # Create ignored entry with different MAC - should be skipped (line 182) # Use a completely different MAC that won't match discovery MAC (AABBCCDDEEFF) other_mac = "11:22:33:44:55:66" @@ -1126,6 +1264,41 @@ async def test_discovery_with_both_ignored_and_normal_entry( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + # Complete the flow + bootstrap.nvr = nvr + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), + patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": DEFAULT_USERNAME, + "password": DEFAULT_PASSWORD, + "api_key": DEFAULT_API_KEY, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) + async def test_discovery_confirm_fallback_to_ip( hass: HomeAssistant, @@ -1173,6 +1346,9 @@ async def test_discovery_confirm_fallback_to_ip( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"]["host"] == DEVICE_IP_ADDRESS assert result["data"]["verify_ssl"] is False + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) async def test_discovery_confirm_with_api_key_error( @@ -1237,6 +1413,9 @@ async def test_discovery_confirm_with_api_key_error( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == _async_unifi_mac_from_hass( + DEVICE_MAC_ADDRESS.upper().replace(":", "") + ) async def test_reconfigure( @@ -1329,59 +1508,6 @@ async def test_reconfigure_different_nvr( assert ufp_reauth_entry.data[CONF_HOST] == "1.1.1.1" -async def test_reconfigure_wrong_nvr( - hass: HomeAssistant, - bootstrap: Bootstrap, - nvr: NVR, - mock_api_bootstrap: Mock, - mock_api_meta_info: Mock, -) -> None: - """Test reconfiguration flow aborts when connected to wrong NVR.""" - # Use the NVR's actual MAC address - nvr_mac = _async_unifi_mac_from_hass(nvr.mac) - - mock_config = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: DEFAULT_HOST, - CONF_USERNAME: DEFAULT_USERNAME, - CONF_PASSWORD: DEFAULT_PASSWORD, - CONF_API_KEY: DEFAULT_API_KEY, - "id": "UnifiProtect", - CONF_PORT: DEFAULT_PORT, - CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, - }, - unique_id=nvr_mac, - ) - mock_config.add_to_hass(hass) - - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" - - # Create a different NVR (user connected to wrong device) - different_nvr = nvr.model_copy() - different_nvr.mac = "112233445566" - bootstrap.nvr = different_nvr - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - **BASE_USER_INPUT, - CONF_HOST: "2.2.2.2", - CONF_USERNAME: "different-username", - CONF_PASSWORD: "different-password", - CONF_API_KEY: "different-api-key", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "wrong_nvr" - # Verify original config wasn't modified - assert mock_config.unique_id == nvr_mac - assert mock_config.data[CONF_HOST] == DEFAULT_HOST - - async def test_reconfigure_auth_error( hass: HomeAssistant, bootstrap: Bootstrap, diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index b478d7bbd2c..cab8791be09 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -1,6 +1,10 @@ """Test UniFi Protect diagnostics.""" -from uiprotect.data import NVR, Light +import re +from typing import Any + +from syrupy.assertion import SnapshotAssertion +from uiprotect.data import Light from homeassistant.core import HomeAssistant @@ -9,52 +13,94 @@ from .utils import MockUFPFixture, init_entry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +# Pattern for hex IDs (24 char hex strings like device/user IDs) +HEX_ID_PATTERN = re.compile(r"^[a-f0-9]{24}$") +# Pattern for MAC addresses (12 hex chars) +MAC_PATTERN = re.compile(r"^[A-F0-9]{12}$") +# Pattern for IPv4 addresses (anonymized by library) +IPV4_PATTERN = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") +# Pattern for UUIDs (anonymized by library) +UUID_PATTERN = re.compile( + r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$" +) +# Pattern for anonymized names (capitalized words with random letters) +ANON_NAME_PATTERN = re.compile(r"^[A-Z][a-z]+( [A-Z][a-z]+)*$") +# Pattern for anonymized emails +EMAIL_PATTERN = re.compile(r"^[A-Za-z]+@example\.com$") +# Pattern for permission strings with embedded IDs +PERMISSION_ID_PATTERN = re.compile(r"^(.+:\*:)[a-f0-9]{24}$") +# Keys that should be redacted for security +REDACT_KEYS = {"accessKey"} +# Keys that contain anonymized names (need normalization) - pattern-matched +NAME_KEYS = {"name", "firstName", "lastName"} +# Keys that always need normalization (not pattern-matched) +ALWAYS_REDACT_KEYS = {"localUsername"} + + +def _normalize_diagnostics(data: Any, parent_key: str | None = None) -> Any: + """Normalize diagnostics data for deterministic snapshots. + + Removes repr fields (contain memory addresses), redacts sensitive keys, + and normalizes hex IDs, MAC addresses, IP addresses, UUIDs, emails, and + anonymized names that may be randomly generated. + """ + if isinstance(data, dict): + return { + k: _normalize_diagnostics(v, k) + for k, v in data.items() + if k != "repr" # Remove repr fields with memory addresses + } + if isinstance(data, list): + return [_normalize_diagnostics(item) for item in data] + if isinstance(data, str): + # Redact sensitive keys + if parent_key in REDACT_KEYS: + return "**REDACTED**" + # Always redact certain keys regardless of pattern + if parent_key in ALWAYS_REDACT_KEYS: + return "**REDACTED_NAME**" + # Normalize anonymized names (pattern-matched) + if parent_key in NAME_KEYS and ANON_NAME_PATTERN.match(data): + return "**REDACTED_NAME**" + if HEX_ID_PATTERN.match(data): + return "**REDACTED_ID**" + if MAC_PATTERN.match(data): + return "**REDACTED_MAC**" + if IPV4_PATTERN.match(data): + return "**REDACTED_IP**" + if UUID_PATTERN.match(data): + return "**REDACTED_UUID**" + if EMAIL_PATTERN.match(data): + return "**REDACTED**@example.com" + # Normalize permission strings with embedded IDs + if match := PERMISSION_ID_PATTERN.match(data): + return f"{match.group(1)}**REDACTED_ID**" + return data + async def test_diagnostics( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" - await init_entry(hass, ufp, [light]) - options = dict(ufp.entry.options) - hass.config_entries.async_update_entry(ufp.entry, options=options) - await hass.async_block_till_done() - diag = await get_diagnostics_for_config_entry(hass, hass_client, ufp.entry) - assert "options" in diag and isinstance(diag["options"], dict) - options = diag["options"] - - assert "bootstrap" in diag and isinstance(diag["bootstrap"], dict) + # Validate that anonymization is working - original values should not appear bootstrap = diag["bootstrap"] - nvr: NVR = ufp.api.bootstrap.nvr - # validate some of the data - assert "nvr" in bootstrap and isinstance(bootstrap["nvr"], dict) - nvr_dict = bootstrap["nvr"] - # should have been anonymized - assert nvr_dict["id"] != nvr.id - assert nvr_dict["mac"] != nvr.mac - assert nvr_dict["host"] != str(nvr.host) - # should have been kept - assert nvr_dict["firmwareVersion"] == nvr.firmware_version - assert nvr_dict["version"] == str(nvr.version) - assert nvr_dict["type"] == nvr.type + nvr = ufp.api.bootstrap.nvr + assert bootstrap["nvr"]["id"] != nvr.id + assert bootstrap["nvr"]["mac"] != nvr.mac + assert bootstrap["nvr"]["host"] != str(nvr.host) + assert bootstrap["lights"][0]["id"] != light.id + assert bootstrap["lights"][0]["mac"] != light.mac + assert bootstrap["lights"][0]["host"] != str(light.host) - assert ( - "lights" in bootstrap - and isinstance(bootstrap["lights"], list) - and len(bootstrap["lights"]) == 1 - ) - light_dict = bootstrap["lights"][0] - # should have been anonymized - assert light_dict["id"] != light.id - assert light_dict["name"] != light.mac - assert light_dict["mac"] != light.mac - assert light_dict["host"] != str(light.host) - # should have been kept - assert light_dict["firmwareVersion"] == light.firmware_version - assert light_dict["type"] == light.type + # Normalize data to remove non-deterministic values (memory addresses, random IDs) + diag_normalized = _normalize_diagnostics(diag) + + assert diag_normalized == snapshot diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 2ff1dbd9fd5..7ec104caf73 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import Camera, Doorlock, IRLEDMode, Light @@ -208,28 +208,32 @@ async def test_number_light_duration( async def test_number_camera_simple( hass: HomeAssistant, ufp: MockUFPFixture, - camera: Camera, + camera_all_features: Camera, description: ProtectNumberEntityDescription, ) -> None: - """Tests all simple numbers for cameras.""" - - await init_entry(hass, ufp, [camera]) - assert_entity_counts(hass, Platform.NUMBER, 4, 4) + """Tests simple numbers for cameras using the all features fixture.""" + await init_entry(hass, ufp, [camera_all_features]) + assert_entity_counts(hass, Platform.NUMBER, 7, 7) assert description.ufp_set_method is not None - camera.__pydantic_fields__[description.ufp_set_method] = Mock( + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera_all_features, description + ) + + camera_all_features.__pydantic_fields__[description.ufp_set_method] = Mock( final=False, frozen=False ) - setattr(camera, description.ufp_set_method, AsyncMock()) + mock_method = AsyncMock() + with patch.object(camera_all_features, description.ufp_set_method, mock_method): + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id, "value": 1.0}, + blocking=True, + ) - _, entity_id = await ids_from_device_description( - hass, Platform.NUMBER, camera, description - ) - - await hass.services.async_call( - "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1.0}, blocking=True - ) + mock_method.assert_called_once_with(1.0) async def test_number_lock_auto_close( diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 501418948c6..0fe3bbc64d0 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode @@ -333,24 +333,23 @@ async def test_switch_camera_simple( doorbell.__pydantic_fields__[description.ufp_set_method] = Mock( final=False, frozen=False ) - setattr(doorbell, description.ufp_set_method, AsyncMock()) - set_method = getattr(doorbell, description.ufp_set_method) + mock_method = AsyncMock() + with patch.object(doorbell, description.ufp_set_method, mock_method): + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) - _, entity_id = await ids_from_device_description( - hass, Platform.SWITCH, doorbell, description - ) + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) - await hass.services.async_call( - "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + mock_method.assert_called_once_with(True) - set_method.assert_called_once_with(True) + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) - await hass.services.async_call( - "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - - set_method.assert_called_with(False) + mock_method.assert_called_with(False) async def test_switch_camera_highfps( diff --git a/tests/components/update/test_trigger.py b/tests/components/update/test_trigger.py new file mode 100644 index 00000000000..d7a4da25605 --- /dev/null +++ b/tests/components/update/test_trigger.py @@ -0,0 +1,212 @@ +"""Test update triggers.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.update import DOMAIN +from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_updates(hass: HomeAssistant) -> list[str]: + """Create multiple update entities associated with different targets.""" + return (await target_entities(hass, DOMAIN))["included"] + + +@pytest.mark.parametrize( + "trigger_key", + [ + "update.update_became_available", + ], +) +async def test_update_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the update triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="update.update_became_available", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_update_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_updates: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the update state trigger fires when any update state changes to a specific state.""" + other_entity_ids = set(target_updates) - {entity_id} + + # Set all updates, including the tested one, to the initial state + for eid in target_updates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check if changing other updates also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="update.update_became_available", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_update_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_updates: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the update state trigger fires when the first update changes to a specific state.""" + other_entity_ids = set(target_updates) - {entity_id} + + # Set all updates, including the tested one, to the initial state + for eid in target_updates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Triggering other updates should not cause the trigger to fire again + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="update.update_became_available", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_update_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_updates: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the update state trigger fires when the last update changes to a specific state.""" + other_entity_ids = set(target_updates) - {entity_id} + + # Set all updates, including the tested one, to the initial state + for eid in target_updates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index d909480c8ea..0a2f5b5e882 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -333,3 +333,82 @@ async def mock_config_entry( ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture(name="mock_vlp_file") +def mock_vlp_content(): + """Mock vlp file content.""" + return b""" + + + + + 21/03/2025 12:30:06 + 11.6.4.0 + + + + 0 + 0 + 0 + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + 192.168.88.9 + 27015 + 7b95834e + True + + False + + + + VMBSIG + + 564D42534947FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0300010001000101FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF + 564D42534947FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF030001000100010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + FFFFFFFFFFFFFFFFFF0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + + + VMB4LEDPWM-20 + + 4368616E6E656C2031FFFFFFFFFFFFFF4368616E6E656C2032FFFFFFFFFFFFFF4368616E6E656C2033FFFFFFFFFFFFFF4368616E6E656C2034FFFFFFFFFFFFFF000000750700160008001700082AF5ECE3E7DDDFDEE5E8F2FD071115181818171A1A1A150E02FFFF1027171A1C151B181A17171209FEF2EAE2E0DCE1DEE4EAF5010DFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000564D42344C454450574D2D3230FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFFFFFFFFFF + 4368616E6E656C2031FFFFFFFFFFFFFF4368616E6E656C2032FFFFFFFFFFFFFF4368616E6E656C2033FFFFFFFFFFFFFF4368616E6E656C2034FFFFFFFFFFFFFF000000750700160008001700082AF5ECE3E7DDDFDEE5E8F2FD071115181818171A1A1A150E02FFFF1027171A1C151B181A17171209FEF2EAE2E0DCE1DEE4EAF5010DFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000564D42344C454450574D2D3230FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFFFFFFFFFF + FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF + + + + + + + + + True + 0 + 0 + 0 + + +
""" diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 36d658f9633..8c53e5a0329 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -1,19 +1,23 @@ """Tests for the Velbus config flow.""" -from collections.abc import Generator +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components.velbus.const import CONF_TLS, DOMAIN +from homeassistant.components.velbus.const import CONF_TLS, CONF_VLP_FILE, DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo +from . import init_integration from .const import PORT_SERIAL from tests.common import MockConfigEntry @@ -40,6 +44,36 @@ def com_port(): return port +@pytest.fixture +def mock_process_uploaded_file( + tmp_path: Path, mock_vlp_file: str +) -> Generator[MagicMock]: + """Mock upload vlp file.""" + file_id_vlp = str(uuid4()) + + @contextmanager + def _mock_process_uploaded_file( + hass: HomeAssistant, uploaded_file_id: str + ) -> Iterator[Path | None]: + with open(tmp_path / uploaded_file_id, "wb") as vlpfile: + vlpfile.write(mock_vlp_file) + yield tmp_path / uploaded_file_id + + with ( + patch( + "homeassistant.components.velbus.config_flow.process_uploaded_file", + side_effect=_mock_process_uploaded_file, + ) as mock_upload, + patch( + "shutil.move", + ), + ): + mock_upload.file_id = { + CONF_VLP_FILE: file_id_vlp, + } + yield mock_upload + + @pytest.fixture(autouse=True) def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -109,16 +143,57 @@ async def test_user_network_succes( }, ) assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Velbus Network" data = result.get("data") assert data assert data[CONF_PORT] == expected -@pytest.mark.usefixtures("controller") +@pytest.mark.usefixtures("controller_connection_failed") +async def test_user_network_connect_failure( + hass: HomeAssistant, +) -> None: + """Test user network config.""" + # inttial menu show + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result + assert result.get("flow_id") + assert result.get("type") is FlowResultType.MENU + assert result.get("step_id") == "user" + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result["type"] is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_TLS: True, + CONF_PASSWORD: "password", + }, + ) + assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"host": "cannot_connect"} + + +@pytest.mark.usefixtures("controller_connection_failed") @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) -async def test_user_usb_succes(hass: HomeAssistant) -> None: +async def test_user_usb_connect_failure(hass: HomeAssistant) -> None: """Test user usb step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -135,6 +210,36 @@ async def test_user_usb_succes(hass: HomeAssistant) -> None: }, ) assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"port": "cannot_connect"} + + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_user_usb_success(hass: HomeAssistant) -> None: + """Test user usb step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, + ) + assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("title") == "Velbus USB" data = result.get("data") @@ -142,6 +247,142 @@ async def test_user_usb_succes(hass: HomeAssistant) -> None: assert data[CONF_PORT] == PORT_SERIAL +@pytest.mark.usefixtures("controller") +async def test_vlp_step_no_modules( + hass: HomeAssistant, + mock_process_uploaded_file: MagicMock, +) -> None: + """Test VLP step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "192.168.88.9", + CONF_PORT: 27015, + CONF_PASSWORD: "", + }, + ) + assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + + with ( + patch( + "homeassistant.components.velbus.async_setup_entry", + return_value=True, + ), + patch( + "velbusaio.vlp_reader.VlpFile.read", + AsyncMock(return_value=True), + ), + patch( + "velbusaio.vlp_reader.VlpFile.get", + return_value=[], + ), + ): + file_id = mock_process_uploaded_file.file_id + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {CONF_VLP_FILE: file_id[CONF_VLP_FILE]}, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {CONF_VLP_FILE: "no_modules"} + + +@pytest.mark.usefixtures("controller") +async def test_vlp_step_success( + hass: HomeAssistant, + mock_process_uploaded_file: MagicMock, +) -> None: + """Test VLP step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "192.168.88.9", + CONF_PORT: 27015, + CONF_PASSWORD: "", + }, + ) + assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + + with ( + patch( + "homeassistant.components.velbus.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "velbusaio.vlp_reader.VlpFile.read", + AsyncMock(return_value=True), + ), + patch( + "velbusaio.vlp_reader.VlpFile.get", + return_value=[1, 2, 3, 4], + ), + ): + file_id = mock_process_uploaded_file.file_id + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {CONF_VLP_FILE: file_id[CONF_VLP_FILE]}, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("controller") +async def test_reconfigure_step( + hass: HomeAssistant, + mock_process_uploaded_file: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Testcase for the reconfigure step.""" + await init_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + + with ( + patch( + "velbusaio.vlp_reader.VlpFile.read", + AsyncMock(return_value=True), + ), + patch( + "velbusaio.vlp_reader.VlpFile.get", + return_value=[1, 2, 3, 4], + ), + ): + file_id = mock_process_uploaded_file.file_id + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {CONF_VLP_FILE: file_id[CONF_VLP_FILE]}, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + @pytest.mark.usefixtures("controller") async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus is already setup.""" @@ -158,7 +399,7 @@ async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: {"next_step_id": "network"}, ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], + result.get("flow_id"), { CONF_TLS: False, CONF_HOST: "127.0.0.1", diff --git a/tests/components/velux/test_init.py b/tests/components/velux/test_init.py new file mode 100644 index 00000000000..bf4e02b47e8 --- /dev/null +++ b/tests/components/velux/test_init.py @@ -0,0 +1,55 @@ +"""Tests for Velux integration initialization and retry behavior. + +These tests verify that setup retries (ConfigEntryNotReady) are triggered +when scene or node loading fails. +""" + +from __future__ import annotations + +from pyvlx.exception import PyVLXException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import AsyncMock, ConfigEntry + + +async def test_setup_retry_on_nodes_failure( + mock_config_entry: ConfigEntry, hass: HomeAssistant, mock_pyvlx: AsyncMock +) -> None: + """Test that a failure loading nodes triggers setup retry. + + The integration loads scenes first, then nodes. If loading raises PyVLXException, + (which could have a multitude of reasons, unfortunately there are no specialized + exceptions that give a reason), the ConfigEntry should enter SETUP_RETRY. + """ + + mock_pyvlx.load_nodes.side_effect = PyVLXException("nodes boom") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pyvlx.load_scenes.assert_awaited_once() + mock_pyvlx.load_nodes.assert_awaited_once() + + +async def test_setup_retry_on_oserror_during_scenes( + mock_config_entry: ConfigEntry, hass: HomeAssistant, mock_pyvlx: AsyncMock +) -> None: + """Test that OSError during scene loading triggers setup retry. + + OSError typically indicates network/connection issues when the gateway + refuses connections or is unreachable. + """ + + mock_pyvlx.load_scenes.side_effect = OSError("Connection refused") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pyvlx.load_scenes.assert_awaited_once() + mock_pyvlx.load_nodes.assert_not_called() diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 364c4d3dd5a..db3bafb2fcc 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -115,7 +115,8 @@ async def test_calls_not_allowed( # Should be problem.pcm from components/voip played_audio_bytes = audio_bytes - done.set() + # Use call_soon_threadsafe because send_audio runs in an executor thread + hass.loop.call_soon_threadsafe(done.set) protocol.transport = Mock() protocol.loop_delay = 0 diff --git a/tests/components/watts/__init__.py b/tests/components/watts/__init__.py new file mode 100644 index 00000000000..5ce8066f60e --- /dev/null +++ b/tests/components/watts/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Watts Vision integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Watts Vision integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py new file mode 100644 index 00000000000..6e78bc397b2 --- /dev/null +++ b/tests/components/watts/conftest.py @@ -0,0 +1,103 @@ +"""Fixtures for the Watts integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from visionpluspython.models import create_device_from_data + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.watts.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +CLIENT_ID = "test_client_id" +CLIENT_SECRET = "test_client_secret" +TEST_USER_ID = "test-user-id" +TEST_ACCESS_TOKEN = "test-access-token" +TEST_REFRESH_TOKEN = "test-refresh-token" +TEST_ID_TOKEN = "test-id-token" +TEST_PROFILE_INFO = "test-profile-info" +TEST_EXPIRES_AT = 9999999999 + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Ensure the application credentials are registered for each test.""" + assert await async_setup_component(hass, "application_credentials", {}) + + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET, name="Watts"), + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.watts.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_watts_client() -> Generator[AsyncMock]: + """Mock a Watts Vision client.""" + with patch( + "homeassistant.components.watts.WattsVisionClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + + discover_data = load_json_array_fixture("discover_devices.json", DOMAIN) + device_report_data = load_json_object_fixture("device_report.json", DOMAIN) + device_detail_data = load_json_object_fixture("device_detail.json", DOMAIN) + + discovered_devices = [ + create_device_from_data(device_data) # type: ignore[arg-type] + for device_data in discover_data + ] + device_report = { + device_id: create_device_from_data(device_data) # type: ignore[arg-type] + for device_id, device_data in device_report_data.items() + } + device_detail = create_device_from_data(device_detail_data) # type: ignore[arg-type] + + client.discover_devices.return_value = discovered_devices + client.get_devices_report.return_value = device_report + client.get_device.return_value = device_detail + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Watts Vision", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": TEST_ACCESS_TOKEN, + "refresh_token": TEST_REFRESH_TOKEN, + "id_token": TEST_ID_TOKEN, + "profile_info": TEST_PROFILE_INFO, + "expires_at": TEST_EXPIRES_AT, + }, + }, + entry_id="01J0BC4QM2YBRP6H5G933CETI8", + unique_id=TEST_USER_ID, + ) diff --git a/tests/components/watts/fixtures/device_detail.json b/tests/components/watts/fixtures/device_detail.json new file mode 100644 index 00000000000..dc9633d15c9 --- /dev/null +++ b/tests/components/watts/fixtures/device_detail.json @@ -0,0 +1,22 @@ +{ + "deviceId": "thermostat_123", + "deviceName": "Living Room Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Living Room", + "isOnline": true, + "currentTemperature": 21.0, + "setpoint": 23.5, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer" + ] +} diff --git a/tests/components/watts/fixtures/device_report.json b/tests/components/watts/fixtures/device_report.json new file mode 100644 index 00000000000..bf3467e769e --- /dev/null +++ b/tests/components/watts/fixtures/device_report.json @@ -0,0 +1,39 @@ +{ + "thermostat_123": { + "deviceId": "thermostat_123", + "deviceName": "Living Room Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Living Room", + "isOnline": true, + "currentTemperature": 20.8, + "setpoint": 22.0, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer" + ] + }, + "thermostat_456": { + "deviceId": "thermostat_456", + "deviceName": "Bedroom Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Bedroom", + "isOnline": true, + "currentTemperature": 19.2, + "setpoint": 21.0, + "thermostatMode": "Program", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": ["Program", "Eco", "Comfort", "Off"] + } +} diff --git a/tests/components/watts/fixtures/discover_devices.json b/tests/components/watts/fixtures/discover_devices.json new file mode 100644 index 00000000000..0bb36039918 --- /dev/null +++ b/tests/components/watts/fixtures/discover_devices.json @@ -0,0 +1,39 @@ +[ + { + "deviceId": "thermostat_123", + "deviceName": "Living Room Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Living Room", + "isOnline": true, + "currentTemperature": 20.5, + "setpoint": 22.0, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": [ + "Program", + "Eco", + "Comfort", + "Off", + "Defrost", + "Timer" + ] + }, + { + "deviceId": "thermostat_456", + "deviceName": "Bedroom Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Bedroom", + "isOnline": true, + "currentTemperature": 19.0, + "setpoint": 21.0, + "thermostatMode": "Program", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": ["Program", "Eco", "Comfort", "Off"] + } +] diff --git a/tests/components/watts/snapshots/test_climate.ambr b/tests/components/watts/snapshots/test_climate.ambr new file mode 100644 index 00000000000..88417d17cbb --- /dev/null +++ b/tests/components/watts/snapshots/test_climate.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_entities[climate.bedroom_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'watts', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'thermostat_456', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.bedroom_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Bedroom Thermostat', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.bedroom_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entities[climate.living_room_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'watts', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'thermostat_123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.living_room_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Living Room Thermostat', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.living_room_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/watts/test_application_credentials.py b/tests/components/watts/test_application_credentials.py new file mode 100644 index 00000000000..35242145edd --- /dev/null +++ b/tests/components/watts/test_application_credentials.py @@ -0,0 +1,15 @@ +"""Test application credentials for Watts integration.""" + +from homeassistant.components.watts.application_credentials import ( + async_get_authorization_server, +) +from homeassistant.components.watts.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant + + +async def test_async_get_authorization_server(hass: HomeAssistant) -> None: + """Test getting authorization server.""" + auth_server = await async_get_authorization_server(hass) + + assert auth_server.authorize_url == OAUTH2_AUTHORIZE + assert auth_server.token_url == OAUTH2_TOKEN diff --git a/tests/components/watts/test_climate.py b/tests/components/watts/test_climate.py new file mode 100644 index 00000000000..aa8b40aec0f --- /dev/null +++ b/tests/components/watts/test_climate.py @@ -0,0 +1,264 @@ +"""Tests for the Watts Vision climate platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from visionpluspython.models import ThermostatMode + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the climate entities.""" + with patch("homeassistant.components.watts.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting temperature.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.living_room_thermostat") + assert state is not None + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_temperature.assert_called_once_with( + "thermostat_123", 23.5 + ) + + +async def test_set_temperature_triggers_fast_polling( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that setting temperature triggers fast polling.""" + await setup_integration(hass, mock_config_entry) + + # Trigger fast polling + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + # Reset mock to count only fast polling calls + mock_watts_client.get_device.reset_mock() + + # Advance time by 5 seconds (fast polling interval) + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_watts_client.get_device.called + mock_watts_client.get_device.assert_called_with("thermostat_123", refresh=True) + + +async def test_fast_polling_stops_after_duration( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that fast polling stops after the duration expires.""" + await setup_integration(hass, mock_config_entry) + + # Trigger fast polling + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + # Reset mock to count only fast polling calls + mock_watts_client.get_device.reset_mock() + + # Should be in fast polling 55s after + mock_watts_client.get_device.reset_mock() + freezer.tick(timedelta(seconds=55)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_watts_client.get_device.called + + mock_watts_client.get_device.reset_mock() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should be called one last time to check if duration expired, then stop + + # Fast polling should be done now + mock_watts_client.get_device.reset_mock() + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert not mock_watts_client.get_device.called + + +async def test_set_hvac_mode_heat( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode to heat.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_123", ThermostatMode.COMFORT + ) + + +async def test_set_hvac_mode_auto( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode to auto.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bedroom_thermostat", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_456", ThermostatMode.PROGRAM + ) + + +async def test_set_hvac_mode_off( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode to off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + mock_watts_client.set_thermostat_mode.assert_called_once_with( + "thermostat_123", ThermostatMode.OFF + ) + + +async def test_set_temperature_api_error( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when setting temperature fails.""" + await setup_integration(hass, mock_config_entry) + + # Make the API call fail + mock_watts_client.set_thermostat_temperature.side_effect = RuntimeError("API Error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the temperature" + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_TEMPERATURE: 23.5, + }, + blocking=True, + ) + + +async def test_set_hvac_mode_value_error( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when setting mode fails.""" + await setup_integration(hass, mock_config_entry) + + mock_watts_client.set_thermostat_mode.side_effect = ValueError("Invalid mode") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the HVAC mode" + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.living_room_thermostat", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py new file mode 100644 index 00000000000..8b56bda1ae1 --- /dev/null +++ b/tests/components/watts/test_config_flow.py @@ -0,0 +1,247 @@ +"""Test the Watts Vision config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.watts.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the full OAuth2 config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.EXTERNAL_STEP + assert "url" in result + assert OAUTH2_AUTHORIZE in result.get("url", "") + assert "response_type=code" in result.get("url", "") + assert "scope=" in result.get("url", "") + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Watts Vision +" + assert "token" in result.get("data", {}) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "user123" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_invalid_token_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the OAuth2 config flow with invalid token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "invalid-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "invalid_token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test OAuth error handling.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={"error": "invalid_grant"}, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "oauth_error" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_timeout( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test OAuth timeout handling.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post(OAUTH2_TOKEN, exc=TimeoutError()) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "oauth_timeout" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_invalid_response( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test OAuth invalid response handling.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post(OAUTH2_TOKEN, status=500, text="invalid json") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "oauth_failed" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_unique_config_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that duplicate config entries are not allowed.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="user123", + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="user123", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py new file mode 100644 index 00000000000..98a85690972 --- /dev/null +++ b/tests/components/watts/test_init.py @@ -0,0 +1,275 @@ +"""Test the Watts Vision integration initialization.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +import pytest +from visionpluspython.exceptions import ( + WattsVisionAuthError, + WattsVisionConnectionError, + WattsVisionDeviceError, + WattsVisionError, + WattsVisionTimeoutError, +) +from visionpluspython.models import create_device_from_data + +from homeassistant.components.watts.const import ( + DISCOVERY_INTERVAL_MINUTES, + DOMAIN, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_entry_success( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload of entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_watts_client.discover_devices.assert_called_once() + + unload_result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert unload_result is True + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_entry_auth_failed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup with authentication failure.""" + config_entry = MockConfigEntry( + domain="watts", + unique_id="test-device-id", + data={ + "device_id": "test-device-id", + "auth_implementation": "watts", + "token": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 0, # Expired token to force refresh + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(OAUTH2_TOKEN, status=401) + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_entry_not_ready( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup when network is temporarily unavailable.""" + config_entry = MockConfigEntry( + domain="watts", + unique_id="test-device-id", + data={ + "device_id": "test-device-id", + "auth_implementation": "watts", + "token": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 0, # Expired token to force refresh + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(OAUTH2_TOKEN, exc=ClientError("Connection timeout")) + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_hub_coordinator_update_failed( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup when hub coordinator update fails.""" + + # Make discover_devices fail + mock_watts_client.discover_devices.side_effect = ConnectionError("API error") + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_entry_server_error_5xx( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup when server returns error.""" + config_entry = MockConfigEntry( + domain="watts", + unique_id="test-device-id", + data={ + "device_id": "test-device-id", + "auth_implementation": "watts", + "token": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": 0, # Expired token to force refresh + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(OAUTH2_TOKEN, status=500) + + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (WattsVisionAuthError("Auth failed"), ConfigEntryState.SETUP_ERROR), + (WattsVisionConnectionError("Connection lost"), ConfigEntryState.SETUP_RETRY), + (WattsVisionTimeoutError("Request timeout"), ConfigEntryState.SETUP_RETRY), + (WattsVisionDeviceError("Device error"), ConfigEntryState.SETUP_RETRY), + (WattsVisionError("API error"), ConfigEntryState.SETUP_RETRY), + (ValueError("Value error"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_discover_devices_errors( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup errors during device discovery.""" + mock_watts_client.discover_devices.side_effect = exception + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert mock_config_entry.state is expected_state + + +async def test_dynamic_device_creation( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are created dynamically.""" + await setup_integration(hass, mock_config_entry) + + assert device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_123")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_456")}) + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "thermostat_789")}) + is None + ) + + new_device_data = { + "deviceId": "thermostat_789", + "deviceName": "Kitchen Thermostat", + "deviceType": "thermostat", + "interface": "homeassistant.components.THERMOSTAT", + "roomName": "Kitchen", + "isOnline": True, + "currentTemperature": 21.0, + "setpoint": 20.0, + "thermostatMode": "Comfort", + "minAllowedTemperature": 5.0, + "maxAllowedTemperature": 30.0, + "temperatureUnit": "C", + "availableThermostatModes": ["Program", "Eco", "Comfort", "Off"], + } + new_device = create_device_from_data(new_device_data) + + current_devices = list(mock_watts_client.discover_devices.return_value) + mock_watts_client.discover_devices.return_value = [*current_devices, new_device] + + freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + new_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_789")} + ) + assert new_device_entry is not None + assert new_device_entry.name == "Kitchen Thermostat" + + state = hass.states.get("climate.kitchen_thermostat") + assert state is not None + + +async def test_stale_device_removal( + hass: HomeAssistant, + mock_watts_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test stale devices are removed dynamically.""" + await setup_integration(hass, mock_config_entry) + + device_123 = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_123")} + ) + device_456 = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_456")} + ) + assert device_123 is not None + assert device_456 is not None + + current_devices = list(mock_watts_client.discover_devices.return_value) + # remove thermostat_456 + mock_watts_client.discover_devices.return_value = [ + d for d in current_devices if d.device_id != "thermostat_456" + ] + + freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify thermostat_456 has been removed + device_456_after_removal = device_registry.async_get_device( + identifiers={(DOMAIN, "thermostat_456")} + ) + assert device_456_after_removal is None diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 48ca34aa8fd..81b37a77ef4 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -24,6 +24,10 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_OK, TYPE_AUTH_REQUIRED, ) +from homeassistant.components.websocket_api.automation import ( + AUTOMATION_COMPONENT_LOOKUP_CACHE, + _get_automation_component_lookup_table, +) from homeassistant.components.websocket_api.commands import ( ALL_CONDITION_DESCRIPTIONS_JSON_CACHE, ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, @@ -3665,6 +3669,7 @@ async def test_get_triggers_conditions_for_target( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, automation_component: str, + caplog: pytest.LogCaptureFixture, ) -> None: """Test get_triggers_for_target/get_conditions_for_target command with mixed target types.""" @@ -3803,7 +3808,9 @@ async def test_get_triggers_conditions_for_target( await hass.async_block_till_done() async def assert_command( - target: dict[str, list[str]], expected: list[str] + target: dict[str, list[str]], + expected: list[str], + expect_lookup_cache: bool = True, ) -> Any: """Call the command and assert expected triggers/conditions.""" await websocket_client.send_json_auto_id( @@ -3815,8 +3822,13 @@ async def test_get_triggers_conditions_for_target( assert msg["success"] assert sorted(msg["result"]) == sorted(expected) + assert ("has no cache yet" not in caplog.text) == expect_lookup_cache + caplog.clear() + # Test entity target - unknown entity - await assert_command({"entity_id": ["light.unknown_entity"]}, []) + await assert_command( + {"entity_id": ["light.unknown_entity"]}, [], expect_lookup_cache=False + ) # Test entity target - entity not in registry await assert_command( @@ -3936,6 +3948,7 @@ async def test_get_services_for_target( mock_load_yaml: Mock, hass: HomeAssistant, websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test get_services_for_target command with mixed target types.""" @@ -4047,7 +4060,11 @@ async def test_get_services_for_target( ) await hass.async_block_till_done() - async def assert_services(target: dict[str, list[str]], expected: list[str]) -> Any: + async def assert_services( + target: dict[str, list[str]], + expected: list[str], + expect_lookup_cache: bool = True, + ) -> Any: """Call the command and assert expected services.""" await websocket_client.send_json_auto_id( {"type": "get_services_for_target", "target": target} @@ -4058,8 +4075,13 @@ async def test_get_services_for_target( assert msg["success"] assert sorted(msg["result"]) == sorted(expected) + assert ("has no cache yet" not in caplog.text) == expect_lookup_cache + caplog.clear() + # Test entity target - unknown entity - await assert_services({"entity_id": ["light.unknown_entity"]}, []) + await assert_services( + {"entity_id": ["light.unknown_entity"]}, [], expect_lookup_cache=False + ) # Test entity target - entity not in registry await assert_services( @@ -4212,7 +4234,7 @@ async def test_get_services_for_target_caching( await call_command() assert mock_get_components.call_count == 1 - first_flat_descriptions = mock_get_components.call_args_list[0][0][3] + first_flat_descriptions = mock_get_components.call_args_list[0][0][4] assert first_flat_descriptions == { "light.turn_on": { "fields": {}, @@ -4227,7 +4249,7 @@ async def test_get_services_for_target_caching( # Second call: should reuse cached flat descriptions await call_command() assert mock_get_components.call_count == 2 - second_flat_descriptions = mock_get_components.call_args_list[1][0][3] + second_flat_descriptions = mock_get_components.call_args_list[1][0][4] assert first_flat_descriptions is second_flat_descriptions # Register a new service to invalidate cache @@ -4237,6 +4259,89 @@ async def test_get_services_for_target_caching( # Third call: cache should be rebuilt await call_command() assert mock_get_components.call_count == 3 - third_flat_descriptions = mock_get_components.call_args_list[2][0][3] + third_flat_descriptions = mock_get_components.call_args_list[2][0][4] assert "new_domain.new_service" in third_flat_descriptions assert third_flat_descriptions is not first_flat_descriptions + + +async def test_get_automation_component_lookup_table_cache( + hass: HomeAssistant, +) -> None: + """Test that _get_automation_component_lookup_table caches and rotates properly.""" + triggers: dict[str, dict[str, Any] | None] = { + "light.turned_on": {"target": {"entity": [{"domain": ["light"]}]}}, + "switch.turned_on": {"target": {"entity": [{"domain": ["switch"]}]}}, + } + conditions: dict[str, dict[str, Any] | None] = { + "light.is_on": {"target": {"entity": [{"domain": ["light"]}]}}, + "sensor.is_above": {"target": {"entity": [{"domain": ["sensor"]}]}}, + } + services: dict[str, dict[str, Any] | None] = { + "light.turn_on": {"target": {"entity": [{"domain": ["light"]}]}}, + "climate.set_temperature": {"target": {"entity": [{"domain": ["climate"]}]}}, + } + + # First call with triggers - cache should be created with 1 entry + trigger_result1 = _get_automation_component_lookup_table(hass, "triggers", triggers) + assert AUTOMATION_COMPONENT_LOOKUP_CACHE in hass.data + cache = hass.data[AUTOMATION_COMPONENT_LOOKUP_CACHE] + assert len(cache) == 1 + + # Second call with same triggers - should return cached result + trigger_result2 = _get_automation_component_lookup_table(hass, "triggers", triggers) + assert trigger_result1 is trigger_result2 + assert len(cache) == 1 + + # Call with conditions + condition_result1 = _get_automation_component_lookup_table( + hass, "conditions", conditions + ) + assert condition_result1 is not trigger_result1 + assert len(cache) == 2 + + # Call with services + service_result1 = _get_automation_component_lookup_table(hass, "services", services) + assert service_result1 is not trigger_result1 + assert service_result1 is not condition_result1 + assert len(cache) == 3 + + # Verify all 3 return cached results + assert ( + _get_automation_component_lookup_table(hass, "triggers", triggers) + is trigger_result1 + ) + assert ( + _get_automation_component_lookup_table(hass, "conditions", conditions) + is condition_result1 + ) + assert ( + _get_automation_component_lookup_table(hass, "services", services) + is service_result1 + ) + assert len(cache) == 3 + + # Add a new triggers description dict - replaces previous triggers cache + new_triggers: dict[str, dict[str, Any] | None] = { + "fan.turned_on": {"target": {"entity": [{"domain": ["fan"]}]}}, + } + _get_automation_component_lookup_table(hass, "triggers", new_triggers) + assert len(cache) == 3 + + # Initial trigger cache entry should have been replaced + trigger_result3 = _get_automation_component_lookup_table(hass, "triggers", triggers) + assert trigger_result3 is not trigger_result1 + assert len(cache) == 3 + + # Verify all 3 return cached results again + assert ( + _get_automation_component_lookup_table(hass, "triggers", triggers) + is trigger_result3 + ) + assert ( + _get_automation_component_lookup_table(hass, "conditions", conditions) + is condition_result1 + ) + assert ( + _get_automation_component_lookup_table(hass, "services", services) + is service_result1 + ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 86c4f1d8cbb..b5035df7afe 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -348,7 +348,13 @@ async def test_setup_with_cloud( assert not hass.config_entries.async_entries(DOMAIN) -@pytest.mark.parametrize("url", ["http://example.com", "https://example.com:444"]) +@pytest.mark.parametrize( + ("url", "expected_message"), + [ + ("http://example.com", "HTTPS is required"), + ("https://example.com:444", "port 443 is required"), + ], +) async def test_setup_no_webhook( hass: HomeAssistant, webhook_config_entry: MockConfigEntry, @@ -356,6 +362,7 @@ async def test_setup_no_webhook( caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, url: str, + expected_message: str, ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") @@ -378,7 +385,7 @@ async def test_setup_no_webhook( await hass.async_block_till_done() mock_async_generate_url.assert_called_once() - assert "https and port 443 is required to register the webhook" in caplog.text + assert expected_message in caplog.text async def test_cloud_disconnect( diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 984b511be7a..fe435135774 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -17,7 +17,18 @@ from tests.common import MockConfigEntry @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") -async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "host_input", + [ + "192.168.1.123", + "http://192.168.1.123", + "https://192.168.1.123/settings", + "https://192.168.1.123:80/settings", + ], +) +async def test_full_user_flow_implementation( + hass: HomeAssistant, host_input: str +) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -28,7 +39,7 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result.get("type") is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + result["flow_id"], user_input={CONF_HOST: host_input} ) assert result.get("title") == "WLED RGB Light" diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 8c45888422a..0907f3a0de2 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -214,7 +214,7 @@ async def test_fail_when_other_device( await hass.async_block_till_done() - assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR assert mock_config_entry.reason assert ( "MAC address does not match the configured device." in mock_config_entry.reason @@ -238,7 +238,7 @@ async def test_fail_when_unsupported_version( await hass.async_block_till_done() - assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR assert mock_config_entry.reason assert ( "The WLED device's firmware version is not supported:" diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index c180b213a31..02132af4611 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -271,7 +271,7 @@ async def test_config_flow_cannot_connect( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} with patch( diff --git a/tests/components/xbox/conftest.py b/tests/components/xbox/conftest.py index 7f04b37a48c..5f665aab36a 100644 --- a/tests/components/xbox/conftest.py +++ b/tests/components/xbox/conftest.py @@ -21,6 +21,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.xbox.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -44,7 +45,7 @@ def mock_config_entry() -> MockConfigEntry: """Mock Xbox configuration entry.""" return MockConfigEntry( domain=DOMAIN, - title="Home Assistant Cloud", + title="GSR Ae", data={ "auth_implementation": "cloud", "token": { @@ -59,6 +60,27 @@ def mock_config_entry() -> MockConfigEntry: }, }, unique_id="271958441785640", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_type="friend", + title="erics273", + unique_id="2533274913657542", + ), + ConfigSubentryData( + data={}, + subentry_type="friend", + title="Ikken Hissatsuu", + unique_id="2533274838782903", + ), + ConfigSubentryData( + data={}, + subentry_type="friend", + title="test", + unique_id="2533274838782904", + ), + ], + minor_version=3, ) @@ -71,6 +93,10 @@ def mock_authentication_manager() -> Generator[AsyncMock]: "homeassistant.components.xbox.config_flow.AuthenticationManager", autospec=True, ) as mock_client, + patch( + "homeassistant.components.xbox.AsyncConfigEntryAuth", + autospec=True, + ), ): client = mock_client.return_value @@ -88,6 +114,7 @@ def mock_xbox_live_client() -> Generator[AsyncMock]: patch( "homeassistant.components.xbox.config_flow.XboxLiveClient", new=mock_client ), + patch("homeassistant.components.xbox.XboxLiveClient", new=mock_client), ): client = mock_client.return_value diff --git a/tests/components/xbox/fixtures/people_friends_own_no_friends.json b/tests/components/xbox/fixtures/people_friends_own_no_friends.json new file mode 100644 index 00000000000..d9e0d2dda9c --- /dev/null +++ b/tests/components/xbox/fixtures/people_friends_own_no_friends.json @@ -0,0 +1,6 @@ +{ + "people": [], + "recommendationSummary": null, + "friendFinderState": null, + "accountLinkDetails": null +} diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index 8c4321594bb..bb20281ccf6 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -1,19 +1,34 @@ """Test the xbox config flow.""" from http import HTTPStatus +from typing import Any from unittest.mock import AsyncMock, patch import pytest +from pythonxbox.api.provider.people.models import PeopleResponse from homeassistant import config_entries -from homeassistant.components.xbox.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.components.xbox.const import ( + CONF_XUID, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryState, + ConfigSubentry, + ConfigSubentryData, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import CLIENT_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -79,6 +94,99 @@ async def test_full_flow( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.parametrize( + ("source", "service_info"), + [ + ( + config_entries.SOURCE_DHCP, + DhcpServiceInfo( + hostname="xboxone", + ip="192.168.0.1", + macaddress="aaaaaaaaaaaa", + ), + ), + ( + config_entries.SOURCE_SSDP, + SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={"manufacturer": "Microsoft Corporation", "modelName": "Xbox One"}, + ), + ), + ], +) +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) +async def test_discovery( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + source: str, + service_info: Any, +) -> None: + """Check DHCP/SSDP discovery.""" + + result = await hass.config_entries.flow.async_init( + "xbox", context={"source": source}, data=service_info + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=Xboxlive.signin+Xboxlive.offline_access" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + ) + + with patch( + "homeassistant.components.xbox.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["result"].unique_id == "271958441785640" + assert result["result"].title == "GSR Ae" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + @pytest.mark.usefixtures( "current_request_with_host", "xbox_live_client", @@ -131,11 +239,345 @@ async def test_form_already_configured( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("xbox_live_client") -async def test_unique_id_migration( +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) +async def test_form_already_configured_as_subentry( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test config entry unique_id migration.""" + """Test we abort flow when entry is already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Ikken Hissatsuu", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_type="friend", + title="GSR Ae", + unique_id="271958441785640", + ), + ], + unique_id="2533274838782903", + minor_version=3, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_subentry" + + +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) +async def test_add_friend_flow(hass: HomeAssistant) -> None: + """Test add friend subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="GSR Ae", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id="271958441785640", + minor_version=3, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_XUID: "2533274913657542"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type="friend", + title="erics273", + unique_id="2533274913657542", + ) + } + + +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) +async def test_add_friend_flow_already_configured(hass: HomeAssistant) -> None: + """Test add friend subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="GSR Ae", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_type="friend", + title="erics273", + unique_id="2533274913657542", + ) + ], + unique_id="271958441785640", + minor_version=3, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_XUID: "2533274913657542"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) +async def test_add_friend_flow_already_configured_as_entry(hass: HomeAssistant) -> None: + """Test add friend subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="GSR Ae", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id="271958441785640", + minor_version=3, + ) + MockConfigEntry( + domain=DOMAIN, + title="erics273", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id="2533274913657542", + minor_version=3, + ).add_to_hass(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_XUID: "2533274913657542"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" + + +@pytest.mark.usefixtures( + "current_request_with_host", + "authentication_manager", +) +async def test_add_friend_flow_no_friends( + hass: HomeAssistant, xbox_live_client: AsyncMock +) -> None: + """Test add friend subentry flow.""" + xbox_live_client.people.get_friends_own.return_value = PeopleResponse( + **await async_load_json_object_fixture( + hass, "people_friends_own_no_friends.json", DOMAIN + ) # type: ignore[reportArgumentType] + ) + config_entry = MockConfigEntry( + domain=DOMAIN, + title="GSR Ae", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id="271958441785640", + minor_version=3, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_friends" + + +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) +async def test_add_friend_flow_config_entry_not_loaded( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test add friend subentry flow.""" + config_entry.add_to_hass(hass) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" + + +@pytest.mark.usefixtures("xbox_live_client", "authentication_manager") +async def test_unique_id_and_friends_migration(hass: HomeAssistant) -> None: + """Test config entry unique_id migration and favorite to subentry migration.""" config_entry = MockConfigEntry( domain=DOMAIN, title="Home Assistant Cloud", @@ -164,10 +606,20 @@ async def test_unique_id_migration( assert config_entry.state is config_entries.ConfigEntryState.LOADED assert config_entry.version == 1 - assert config_entry.minor_version == 2 + assert config_entry.minor_version == 3 assert config_entry.unique_id == "271958441785640" assert config_entry.title == "GSR Ae" + # Assert favorite friends migrated to subentries + assert len(config_entry.subentries) == 2 + subentries = list(config_entry.subentries.values()) + assert subentries[0].unique_id == "2533274838782903" + assert subentries[0].title == "Ikken Hissatsuu" + assert subentries[0].subentry_type == "friend" + assert subentries[1].unique_id == "2533274913657542" + assert subentries[1].title == "erics273" + assert subentries[1].subentry_type == "friend" + @pytest.mark.usefixtures( "xbox_live_client", diff --git a/tests/components/xbox/test_image.py b/tests/components/xbox/test_image.py index 96fc978f1d0..7e04192636f 100644 --- a/tests/components/xbox/test_image.py +++ b/tests/components/xbox/test_image.py @@ -115,12 +115,12 @@ async def test_load_image_from_url( "rgWHJigthrlsHCxEOMG9UGNdojCYasYt6MJHBjmxmtuAHJeo.sOkUiPmg4JHXvOS82c3UOrvdJTDaCKwCwHPJ0t0Plha8oHFC1i_o-&format=png" ).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2") - freezer.tick(timedelta(seconds=10)) + freezer.tick(timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("image.gsr_ae_gamerpic")) - assert state.state == "2025-06-16T00:00:10+00:00" + assert state.state == "2025-06-16T00:00:15+00:00" access_token = state.attributes["access_token"] assert ( diff --git a/tests/components/xbox/test_media_source.py b/tests/components/xbox/test_media_source.py index 9c4e2a4fbe0..d0344ca61d2 100644 --- a/tests/components/xbox/test_media_source.py +++ b/tests/components/xbox/test_media_source.py @@ -87,12 +87,29 @@ async def test_browse_media( async def test_browse_media_accounts( hass: HomeAssistant, - config_entry: MockConfigEntry, xbox_live_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test browsing media we get account view if more than 1 account is configured.""" - + config_entry = MockConfigEntry( + domain=DOMAIN, + title="GSR Ae", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id="271958441785640", + minor_version=3, + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -120,7 +137,7 @@ async def test_browse_media_accounts( }, }, unique_id="277923030577271", - minor_version=2, + minor_version=3, ) config_entry2.add_to_hass(hass) await hass.config_entries.async_setup(config_entry2.entry_id) @@ -213,7 +230,7 @@ async def test_browse_media_not_configured_exception( }, unique_id="2533274838782903", disabled_by=ConfigEntryDisabler.USER, - minor_version=2, + minor_version=3, ) config_entry.add_to_hass(hass) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 16e0c8fca63..8d4d96060dc 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1729,7 +1729,7 @@ def advanced_pick_radio( user_input={"next_step_id": config_flow.SETUP_STRATEGY_ADVANCED}, ) - assert advanced_strategy_result["type"] == FlowResultType.MENU + assert advanced_strategy_result["type"] is FlowResultType.MENU assert advanced_strategy_result["step_id"] == "choose_formation_strategy" return advanced_strategy_result diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1a765288cc1..0e27ef5a66f 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -326,6 +326,12 @@ def ge_12730_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fan_ge_12730_state.json", DOMAIN) +@pytest.fixture(name="jasco_14314_state", scope="package") +def jasco_14314_state_fixture() -> dict[str, Any]: + """Load the Jasco 14314 node state fixture data.""" + return load_json_object_fixture("fan_jasco_14314_state.json", DOMAIN) + + @pytest.fixture(name="enbrighten_58446_zwa4013_state", scope="package") def enbrighten_58446_zwa4013_state_fixture() -> dict[str, Any]: """Load the Enbrighten/GE 58446/zwa401 node state fixture data.""" @@ -1109,6 +1115,14 @@ def ge_12730_fixture(client, ge_12730_state) -> Node: return node +@pytest.fixture(name="jasco_14314") +def jasco_14314_fixture(client, jasco_14314_state) -> Node: + """Mock a Jasco 14314 fan controller node.""" + node = Node(client, copy.deepcopy(jasco_14314_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="enbrighten_58446_zwa4013") def enbrighten_58446_zwa4013_fixture(client, enbrighten_58446_zwa4013_state) -> Node: """Mock a Enbrighten_58446/zwa4013 fan controller node.""" diff --git a/tests/components/zwave_js/fixtures/fan_jasco_14314_state.json b/tests/components/zwave_js/fixtures/fan_jasco_14314_state.json new file mode 100644 index 00000000000..a66e82062fa --- /dev/null +++ b/tests/components/zwave_js/fixtures/fan_jasco_14314_state.json @@ -0,0 +1,450 @@ +{ + "nodeId": 24, + "index": 0, + "status": 4, + "ready": true, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 8, + "label": "Fan Switch" + } + }, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 99, + "productId": 12600, + "productType": 18756, + "firmwareVersion": "5.24", + "zwavePlusVersion": 1, + "deviceConfig": { + "manufacturer": "Jasco", + "manufacturerId": 99, + "label": "14314 / ZW4002", + "description": "In-Wall Fan Speed Control, 500S", + "devices": [ + { + "productType": "0x4944", + "productId": "0x3138" + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "mapBasicSet": "event" + }, + "metadata": { + "inclusion": "Press and release the top or bottom of the smart switch", + "exclusion": "Press and release the top or bottom of the smart switch", + "reset": "Quickly press the top button three times, then immediately press the bottom button three times. The LED will flash on/off 5 times when completed successfully", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/1937/Binder1.pdf" + } + }, + "label": "14314 / ZW4002", + + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0063:0x4944:0x3138:5.24", + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Indicator", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 12600 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 18756 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["5.24"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "4.54" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + } + ], + "endpoints": [ + { + "nodeId": 24, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 8, + "label": "Fan Switch" + } + }, + "commandClasses": [ + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 71e0c963f52..8c03f90f555 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -5264,7 +5264,7 @@ async def test_addon_rf_region_new_network( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "rf_region" # Check that all expected RF regions are available @@ -5435,7 +5435,7 @@ async def test_addon_rf_region_migrate_network( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "rf_region" result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 25ab6a87200..f57f412f2ad 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -447,6 +447,99 @@ async def test_ge_12730_fan(hass: HomeAssistant, client, ge_12730, integration) assert state.state == STATE_UNKNOWN +async def test_jasco_14314_fan( + hass: HomeAssistant, client, jasco_14314, integration +) -> None: + """Test a Jasco 14314 fan with 3 fixed speeds.""" + node = jasco_14314 + node_id = 24 + entity_id = "fan.in_wall_fan_speed_control_500s" + + async def get_zwave_speed_from_percentage(percentage): + """Set the fan to a particular percentage and get the resulting Zwave speed.""" + client.async_send_command.reset_mock() + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "percentage": percentage}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + return args["value"] + + async def get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # This device has the speeds: + # low = 1-32, med = 33-66, high = 67-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(1, 33)], # percentages 1-33 → zwave 1-32 + [range(34, 68), range(33, 67)], # percentages 34-67 → zwave 33-66 + [range(68, 101), range(67, 100)], # percentages 68-100 → zwave 67-99 + ] + + for percentages, zwave_speeds in percentages_to_zwave_speeds: + for percentage in percentages: + actual_zwave_speed = await get_zwave_speed_from_percentage(percentage) + assert actual_zwave_speed in zwave_speeds + for zwave_speed in zwave_speeds: + actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) + assert actual_percentage in percentages + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PERCENTAGE_STEP] == pytest.approx(33.3333, rel=1e-3) + assert state.attributes[ATTR_PRESET_MODES] == [] + + # Test value is None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration ) -> None: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index a5c3eef6f28..8faf2a28ce6 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -2332,7 +2332,7 @@ async def test_driver_ready_event( await hass.async_block_till_done() assert len(config_entry_state_changes) == 4 - assert config_entry_state_changes[0] == ConfigEntryState.UNLOAD_IN_PROGRESS - assert config_entry_state_changes[1] == ConfigEntryState.NOT_LOADED - assert config_entry_state_changes[2] == ConfigEntryState.SETUP_IN_PROGRESS - assert config_entry_state_changes[3] == ConfigEntryState.LOADED + assert config_entry_state_changes[0] is ConfigEntryState.UNLOAD_IN_PROGRESS + assert config_entry_state_changes[1] is ConfigEntryState.NOT_LOADED + assert config_entry_state_changes[2] is ConfigEntryState.SETUP_IN_PROGRESS + assert config_entry_state_changes[3] is ConfigEntryState.LOADED diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 830154f9c0a..92702b6f1a3 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -36,7 +36,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionCheckerType, + ConditionChecker, async_validate_condition_config, ) from homeassistant.helpers.template import Template @@ -2126,16 +2126,16 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_get_checker(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Evaluate state based on configuration.""" - return lambda hass, vars: True + return lambda **kwargs: True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_get_checker(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionChecker: """Evaluate state based on configuration.""" - return lambda hass, vars: False + return lambda **kwargs: False async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: return { diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 46d84ea768d..a8a16fa6730 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -663,7 +663,7 @@ async def test_get_request_host_no_host_header(hass: HomeAssistant) -> None: assert _get_request_host() is None -@patch("homeassistant.components.hassio.is_hassio", Mock(return_value=True)) +@patch("homeassistant.helpers.hassio.is_hassio", Mock(return_value=True)) @patch( "homeassistant.components.hassio.get_host_info", Mock(return_value={"hostname": "homeassistant"}), diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 6a5107700ed..9deb8ecb547 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -342,33 +342,33 @@ async def test_menu_step(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "user"} ) - assert result["type"] == FlowResultType.MENU + assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "option1"}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "option1" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.MENU + assert result["type"] is FlowResultType.MENU assert result["step_id"] == "menu2" result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "option3"}, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "option3" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "option4" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_schema_none(hass: HomeAssistant) -> None: @@ -391,15 +391,15 @@ async def test_schema_none(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "user"} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "option1" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "option3" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_last_step(hass: HomeAssistant) -> None: @@ -425,22 +425,22 @@ async def test_last_step(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "user"} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "step1" assert result["last_step"] is False result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "step2" assert result["last_step"] is None result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "step3" assert result["last_step"] is True result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_next_step_function(hass: HomeAssistant) -> None: @@ -468,15 +468,15 @@ async def test_next_step_function(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "user"} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "step1" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "step2" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_suggested_values( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 59e9f957eb0..570d263d3eb 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -2699,7 +2699,7 @@ async def test_deprecated_service_target_selector_class(hass: HomeAssistant) -> assert selector.device_ids == {"device1", "device2"} assert selector.floor_ids == {"first_floor"} assert selector.label_ids == {"label1", "label2"} - assert selector.has_any_selector is True + assert selector.has_any_target is True async def test_deprecated_selected_entities_class( diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index ad140834199..a3363c8d60c 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -30,7 +30,7 @@ async def test_get_system_info_supervisor_not_available( patch("platform.system", return_value="Linux"), patch("homeassistant.helpers.system_info.is_docker_env", return_value=True), patch("homeassistant.helpers.system_info.is_official_image", return_value=True), - patch.object(hassio, "is_hassio", return_value=True), + patch("homeassistant.helpers.hassio.is_hassio", return_value=True), patch.object(hassio, "get_info", return_value=None), patch("homeassistant.helpers.system_info.cached_get_user", return_value="root"), ): diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 3c19a9c9a43..92a8a0e2ee2 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -461,12 +461,16 @@ def registries_mock(hass: HomeAssistant) -> None: ), ], ) +@pytest.mark.parametrize( + "selection_class", [target.TargetSelection, target.TargetSelectorData] +) @pytest.mark.usefixtures("registries_mock") async def test_extract_referenced_entity_ids( hass: HomeAssistant, selector_config: ConfigType, expand_group: bool, expected_selected: target.SelectedEntities, + selection_class, ) -> None: """Test extract_entity_ids method.""" hass.states.async_set("light.Bowl", STATE_ON) @@ -486,10 +490,10 @@ async def test_extract_referenced_entity_ids( order=None, ) - target_data = target.TargetSelectorData(selector_config) + target_selection = selection_class(selector_config) assert ( target.async_extract_referenced_entity_ids( - hass, target_data, expand_group=expand_group + hass, target_selection, expand_group=expand_group ) == expected_selected ) diff --git a/tests/helpers/test_typing.py b/tests/helpers/test_typing.py deleted file mode 100644 index 5b50a8864de..00000000000 --- a/tests/helpers/test_typing.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Test typing helper module.""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from homeassistant.core import Context, Event, HomeAssistant, ServiceCall -from homeassistant.helpers import typing as ha_typing - -from tests.common import import_and_test_deprecated_alias - - -@pytest.mark.parametrize( - ("alias_name", "replacement", "breaks_in_ha_version"), - [ - ("ContextType", Context, "2025.5"), - ("EventType", Event, "2025.5"), - ("HomeAssistantType", HomeAssistant, "2025.5"), - ("ServiceCallType", ServiceCall, "2025.5"), - ], -) -def test_deprecated_aliases( - caplog: pytest.LogCaptureFixture, - alias_name: str, - replacement: Any, - breaks_in_ha_version: str, -) -> None: - """Test deprecated aliases.""" - import_and_test_deprecated_alias( - caplog, - ha_typing, - alias_name, - replacement, - breaks_in_ha_version, - ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 8f6a59a2915..e3d32354e49 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -130,8 +130,16 @@ async def test_async_enable_logging( cleanup_log_files() +@pytest.mark.parametrize( + ("extra_env", "log_file_count", "old_log_file_count"), + [({}, 0, 1), ({"HA_DUPLICATE_LOG_FILE": "1"}, 1, 0)], +) async def test_async_enable_logging_supervisor( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + extra_env: dict[str, str], + log_file_count: int, + old_log_file_count: int, ) -> None: """Test to ensure the default log file is not created on Supervisor installations.""" @@ -141,14 +149,14 @@ async def test_async_enable_logging_supervisor( assert len(glob.glob(ARG_LOG_FILE)) == 0 with ( - patch.dict(os.environ, {"SUPERVISOR": "1"}), + patch.dict(os.environ, {"SUPERVISOR": "1", **extra_env}), patch( "homeassistant.bootstrap.async_activate_log_queue_handler" ) as mock_async_activate_log_queue_handler, patch("logging.getLogger"), ): await bootstrap.async_enable_logging(hass) - assert len(glob.glob(CONFIG_LOG_FILE)) == 0 + assert len(glob.glob(CONFIG_LOG_FILE)) == log_file_count mock_async_activate_log_queue_handler.assert_called_once() mock_async_activate_log_queue_handler.reset_mock() @@ -162,9 +170,10 @@ async def test_async_enable_logging_supervisor( await hass.async_add_executor_job(write_log_file) assert len(glob.glob(CONFIG_LOG_FILE)) == 1 assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == 0 + await bootstrap.async_enable_logging(hass) - assert len(glob.glob(CONFIG_LOG_FILE)) == 0 - assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == 1 + assert len(glob.glob(CONFIG_LOG_FILE)) == log_file_count + assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == old_log_file_count mock_async_activate_log_queue_handler.assert_called_once() mock_async_activate_log_queue_handler.reset_mock() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b358a6fb50f..fdd83289a7c 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1382,7 +1382,7 @@ async def test_reauth_issue_flow_returns_abort( issue = await _test_reauth_issue(hass, manager, issue_registry) result = await manager.flow.async_configure(issue.data["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert len(issue_registry.issues) == 0 @@ -3415,7 +3415,7 @@ async def test_unique_id_update_existing_entry_without_reload( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" @@ -3468,7 +3468,7 @@ async def test_unique_id_update_existing_entry_with_reload( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" @@ -3489,7 +3489,7 @@ async def test_unique_id_update_existing_entry_with_reload( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "2.2.2.2" @@ -3545,7 +3545,7 @@ async def test_unique_id_from_discovery_in_setup_retry( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert len(async_reload.mock_calls) == 0 @@ -3567,7 +3567,7 @@ async def test_unique_id_from_discovery_in_setup_retry( ) await hass.async_block_till_done() - assert discovery_result["type"] == FlowResultType.ABORT + assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" assert len(async_reload.mock_calls) == 1 @@ -3613,7 +3613,7 @@ async def test_unique_id_not_update_existing_entry( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data["host"] == "0.0.0.0" assert entry.data["additional"] == "data" @@ -5506,7 +5506,7 @@ async def test_async_abort_entries_match( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason # For a domain with no entries, there should never be a match @@ -5519,7 +5519,7 @@ async def test_async_abort_entries_match( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_match" @@ -5565,7 +5565,7 @@ async def test_async_abort_entries_match_context( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason @@ -5659,7 +5659,7 @@ async def test_async_abort_entries_match_options_flow( original_entry.entry_id, data=matchers ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason @@ -5865,7 +5865,7 @@ async def test_unique_id_update_while_setup_in_progress( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" @@ -6620,7 +6620,7 @@ async def test_update_entry_and_reload( if raises: assert isinstance(err, raises) else: - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason # Assert entry was reloaded assert len(comp.async_setup_entry.mock_calls) == calls_entry_load_unload[0] @@ -6697,7 +6697,7 @@ async def test_update_entry_without_reload( assert entry.data == {"vendor": "data2"} assert entry.options == {"vendor": "options2"} assert entry.state == config_entries.ConfigEntryState.LOADED - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason # Assert entry is not reloaded assert len(comp.async_setup_entry.mock_calls) == 1 @@ -6844,7 +6844,7 @@ async def test_update_subentry_and_abort( if raises: assert isinstance(err, raises) else: - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -9204,12 +9204,12 @@ async def test_options_flow_config_entry( == "The config entry is not available during initialisation" ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["errors"] == {} result = await hass.config_entries.options.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["errors"]["entry_id"] == original_entry.entry_id assert result["errors"]["entry"] is original_entry @@ -9218,7 +9218,7 @@ async def test_options_flow_config_entry( options_flow.handler = "123" result = await hass.config_entries.options.async_configure(result["flow_id"], {}) result = await hass.config_entries.options.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["errors"]["entry_id"] == "123" assert isinstance(result["errors"]["entry"], config_entries.UnknownEntry) @@ -9228,7 +9228,7 @@ async def test_options_flow_config_entry( result = await hass.config_entries.options.async_configure( result["flow_id"], {"abort": True} ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "abort" @@ -9404,7 +9404,7 @@ async def test_add_description_placeholder_automatically( assert len(flows) == 1 result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {"name": "test_title"} @@ -9428,7 +9428,7 @@ async def test_add_description_placeholder_automatically_not_overwrites( assert len(flows) == 1 result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {"name": "Custom title"} diff --git a/tests/test_core.py b/tests/test_core.py index 0daaafe74cf..19ab0b8cace 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -49,7 +49,6 @@ from homeassistant.core import ( callback, get_release_channel, ) -from homeassistant.core_config import Config from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, @@ -64,12 +63,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task from homeassistant.util.read_only_dict import ReadOnlyDict -from .common import ( - async_capture_events, - async_mock_service, - help_test_all, - import_and_test_deprecated_alias, -) +from .common import async_capture_events, async_mock_service PST = dt_util.get_time_zone("America/Los_Angeles") @@ -3019,16 +3013,6 @@ async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: assert not evt.is_set() -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(ha) - - -def test_deprecated_config(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated Config class.""" - import_and_test_deprecated_alias(caplog, ha, "Config", Config, "2025.11") - - def test_one_time_listener_repr(hass: HomeAssistant) -> None: """Test one time listener repr."""