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..d8127c3d6a4 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 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f9ba0ff248d..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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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 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 093fe2f0e68..453ffbd73ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -664,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 @@ -1194,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 @@ -1797,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 52cf8bc774d..6fe0f14bb24 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "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/airpatrol/climate.py b/homeassistant/components/airpatrol/climate.py index ff5abd98103..711c2655e98 100644 --- a/homeassistant/components/airpatrol/climate.py +++ b/homeassistant/components/airpatrol/climate.py @@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity): super().__init__(coordinator, unit_id) self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}" - @property - def climate_data(self) -> dict[str, Any]: - """Return the climate data.""" - return self.device_data.get("climate") or {} - @property def params(self) -> dict[str, Any]: """Return the current parameters for the climate entity.""" return self.climate_data.get("ParametersData") or {} - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and bool(self.climate_data) - @property def current_humidity(self) -> float | None: """Return the current humidity.""" diff --git a/homeassistant/components/airpatrol/const.py b/homeassistant/components/airpatrol/const.py index 9bcc9451b5b..b390f5eec21 100644 --- a/homeassistant/components/airpatrol/const.py +++ b/homeassistant/components/airpatrol/const.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform DOMAIN = "airpatrol" LOGGER = logging.getLogger(__package__) -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=1) AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError) diff --git a/homeassistant/components/airpatrol/entity.py b/homeassistant/components/airpatrol/entity.py index 73c7b75a220..0f4e14c0086 100644 --- a/homeassistant/components/airpatrol/entity.py +++ b/homeassistant/components/airpatrol/entity.py @@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]): """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 + 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/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/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/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 871e6a03d9e..b6f2dd33838 100644 --- a/homeassistant/components/anglian_water/manifest.json +++ b/homeassistant/components/anglian_water/manifest.json @@ -1,6 +1,7 @@ { "domain": "anglian_water", "name": "Anglian Water", + "after_dependencies": ["recorder"], "codeowners": ["@pantherale0"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/anglian_water", @@ -8,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["pyanglianwater"], "quality_scale": "bronze", - "requirements": ["pyanglianwater==3.0.0"] + "requirements": ["pyanglianwater==3.1.0"] } 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/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/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/automation/__init__.py b/homeassistant/components/automation/__init__.py index 05e294ca812..c87d8836cfe 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -125,14 +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/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/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/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/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/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/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/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 5bfb5237133..f014cbdbe88 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -6,5 +6,5 @@ "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/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/ekeybionyx/manifest.json b/homeassistant/components/ekeybionyx/manifest.json index 6926877d6e2..4fa07e197ff 100644 --- a/homeassistant/components/ekeybionyx/manifest.json +++ b/homeassistant/components/ekeybionyx/manifest.json @@ -8,5 +8,5 @@ "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/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/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/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/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/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/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/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/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/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/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/config_flow.py b/homeassistant/components/gentex_homelink/config_flow.py index dceb79143e8..512f9df5278 100644 --- a/homeassistant/components/gentex_homelink/config_flow.py +++ b/homeassistant/components/gentex_homelink/config_flow.py @@ -5,6 +5,7 @@ from typing import Any import botocore.exceptions from homelink.auth.srp_auth import SRPAuth +import jwt import voluptuous as vol from homeassistant.config_entries import ConfigFlowResult @@ -38,8 +39,6 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """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 61daf71ba0e..9e03b16fc79 100644 --- a/homeassistant/components/gentex_homelink/coordinator.py +++ b/homeassistant/components/gentex_homelink/coordinator.py @@ -1,10 +1,9 @@ -"""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 functools import partial -import logging from typing import TypedDict from homelink.model.device import Device @@ -14,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.util.ssl import get_default_context -_LOGGER = logging.getLogger(__name__) - type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator] type EventCallback = Callable[[HomeLinkEventData], None] 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/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_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/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 ffa44dfd6a8..d9d70035c88 100644 --- a/homeassistant/components/hanna/manifest.json +++ b/homeassistant/components/hanna/manifest.json @@ -4,6 +4,7 @@ "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.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 031432fe638..ee607829b7a 100644 --- a/homeassistant/components/kaleidescape/manifest.json +++ b/homeassistant/components/kaleidescape/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@SteveEasley"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kaleidescape", + "integration_type": "device", "iot_class": "local_push", "requirements": ["pykaleidescape==1.0.2"], "ssdp": [ 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/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/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 3d5e88420a7..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.", @@ -675,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/strings.json b/homeassistant/components/lamarzocco/strings.json index 01eec9fba7f..c39be40ddbb 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -197,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}" }, @@ -223,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 a2b867d057a..260c9bd3bf0 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -36,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) diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index eabda852d1a..4066cef747f 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -27,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) 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/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/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/select.py b/homeassistant/components/matter/select.py index ee6b537e9b3..cc7041965fe 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -638,7 +638,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, entity_description=MatterMapSelectEntityDescription( key="DoorLockOperatingMode", - entity_category=EntityCategory.CONFIG, translation_key="door_lock_operating_mode", list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes, device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get, 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 4d5325f235f..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 @@ -15,13 +16,6 @@ from .entity import MealieEntity PARALLEL_UPDATES = 0 -SUPPORTED_MEALPLAN_ENTRY_TYPES = [ - MealplanEntryType.BREAKFAST, - MealplanEntryType.DINNER, - MealplanEntryType.LUNCH, - MealplanEntryType.SIDE, -] - async def async_setup_entry( hass: HomeAssistant, @@ -30,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 SUPPORTED_MEALPLAN_ENTRY_TYPES + 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/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/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/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/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 c38f465a62f..53c16fa9361 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoauth==1.0.0", "pynintendoparental==2.1.1"] + "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/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/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/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 239fdcf3388..031c13122c9 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -2,7 +2,7 @@ "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", 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/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/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/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index a63fa0e65c1..3c419873b66 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -37,10 +37,12 @@ from .const import ( PLATFORMS, ) from .coordinator import ( + RoborockB01Q7UpdateCoordinator, RoborockConfigEntry, RoborockCoordinators, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, + RoborockDataUpdateCoordinatorB01, RoborockWashingMachineUpdateCoordinator, RoborockWetDryVacUpdateCoordinator, ) @@ -131,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) @@ -208,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) @@ -229,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", @@ -241,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 baf1973cc00..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 @@ -58,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] @@ -469,3 +480,91 @@ class RoborockWetDryVacUpdateCoordinator( 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 9178d58eb68..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.12.2", + "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 e51deb7f79d..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" }, 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/shelly/strings.json b/homeassistant/components/shelly/strings.json index 5fcffcf728f..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" }, 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/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/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/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/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/trigger.py b/homeassistant/components/switch/trigger.py index 13a52fbdd72..6797cad0f6b 100644 --- a/homeassistant/components/switch/trigger.py +++ b/homeassistant/components/switch/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_on": make_entity_state_trigger(DOMAIN, STATE_ON), - "turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF), + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), } 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/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 7170c7baad8..943efe00751 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -2,10 +2,8 @@ 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 @@ -13,17 +11,13 @@ from telegram.error import InvalidToken, TelegramError import voluptuous as vol from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_SERVICE, - CONF_API_KEY, CONF_PLATFORM, - CONF_SOURCE, - CONF_URL, Platform, ) from homeassistant.core import ( @@ -69,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, @@ -89,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, @@ -122,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( { @@ -165,21 +127,26 @@ 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 = vol.All( cv.deprecated(ATTR_TIMEOUT), BASE_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_TITLE): cv.string} + { + 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), - BASE_SERVICE_SCHEMA.extend( + 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, @@ -195,6 +162,7 @@ SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All( CHAT_ACTION_UPLOAD_VIDEO_NOTE, ) ), + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), } ), ) @@ -208,6 +176,7 @@ SERVICE_SCHEMA_BASE_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), } ) @@ -227,6 +196,7 @@ SERVICE_SCHEMA_SEND_LOCATION = vol.All( { vol.Required(ATTR_LONGITUDE): cv.string, vol.Required(ATTR_LATITUDE): cv.string, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), } ), ) @@ -244,18 +214,25 @@ SERVICE_SCHEMA_SEND_POLL = vol.All( 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 = vol.All( cv.deprecated(ATTR_TIMEOUT), - SERVICE_SCHEMA_BASE_SEND_FILE.extend( + 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, } ), ) @@ -385,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.""" diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 9cd3f001266..325aa7ffbc6 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -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/strings.json b/homeassistant/components/telegram_bot/strings.json index 1e296a81c12..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" } } @@ -219,18 +220,6 @@ } }, "title": "The `timeout` parameter for {integration_title} is being removed" - }, - "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" } }, "options": { diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index c67cc6463b1..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.3.0", "teslemetry-stream==0.7.10"] + "requirements": ["tesla-fleet-api==1.3.0", "teslemetry-stream==0.8.2"] } diff --git a/homeassistant/components/text/trigger.py b/homeassistant/components/text/trigger.py index c29668f4f52..d662a8c978c 100644 --- a/homeassistant/components/text/trigger.py +++ b/homeassistant/components/text/trigger.py @@ -17,10 +17,6 @@ class TextChangedTrigger(EntityTriggerBase): _domain = DOMAIN _schema = ENTITY_STATE_TRIGGER_SCHEMA - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the old and new states are different.""" - 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) 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 fdae81fe900..f31ea7d7473 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -96,18 +96,19 @@ class _AlarmActionWrapper(DPCodeEnumWrapper): "trigger": "sos", } - 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 + 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}") @@ -182,12 +183,13 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): self._state_wrapper = state_wrapper # Determine supported modes - if action_wrapper.supports_action("arm_home"): - self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME - if action_wrapper.supports_action("arm_away"): - self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY - if action_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: diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 74395d43d41..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 |= ( diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index c97fd0e5c51..5c82d1f8f40 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -22,8 +22,13 @@ 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 .type_information import IntegerTypeInformation +from .models import ( + DeviceWrapper, + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from .type_information import EnumTypeInformation, IntegerTypeInformation from .util import RemapHelper @@ -73,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: @@ -278,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 @@ -358,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: @@ -374,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: @@ -414,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: @@ -427,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: @@ -446,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/event.py b/homeassistant/components/tuya/event.py index 48d67a59ad6..701ee1c7840 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -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: @@ -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 f7e94360d39..f82f7bb795b 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -77,19 +77,15 @@ 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): @@ -100,10 +96,6 @@ class _FanSpeedIntegerWrapper(DPCodeIntegerWrapper): super().__init__(dpcode, type_information) self._remap_helper = RemapHelper.from_type_information(type_information, 1, 100) - def get_speed_count(self) -> int: - """Get the number of speeds supported by the fan.""" - return 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: @@ -197,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 24bb58f4e2e..aef9908c825 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -173,22 +173,21 @@ class _ColorDataWrapper(DPCodeJsonWrapper): s_type = DEFAULT_S_TYPE v_type = DEFAULT_V_TYPE - 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(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(status["v"])) - - 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( @@ -596,7 +595,7 @@ def _get_color_data_wrapper( 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 @@ -706,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 @@ -765,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) ), ) @@ -792,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) @@ -806,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 44cbd14b24a..e1a23aa4bc2 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -21,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 @@ -133,6 +135,12 @@ 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.""" @@ -154,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.""" 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/type_information.py b/homeassistant/components/tuya/type_information.py index 8bd059d5c20..ff06b22c28b 100644 --- a/homeassistant/components/tuya/type_information.py +++ b/homeassistant/components/tuya/type_information.py @@ -200,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) 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 22b2a926112..d288fe66e2b 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -346,28 +346,28 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="motion_enabled", - translation_key="detections_motion", + 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", + 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", + 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", + translation_key="light_sensor_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -453,6 +453,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", translation_key="co_alarm_detected", + device_class=BinarySensorDeviceClass.CO, ufp_required_field="can_detect_co", ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 942da255a4e..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 @@ -45,9 +45,6 @@ class ProtectButtonEntityDescription( ufp_press: str | None = None -DEVICE_CLASS_CHIME_BUTTON: Final = "unifiprotect__chime_button" - - ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="reboot", @@ -84,7 +81,6 @@ CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="play", translation_key="play_chime", - device_class=DEVICE_CLASS_CHIME_BUTTON, ufp_press="play", ), ProtectButtonEntityDescription( 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 8210145f3f8..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() diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index a9a403fb3d5..9fccfcf97ac 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -1,6 +1,9 @@ { "entity": { "binary_sensor": { + "alarm_sound_detection": { + "default": "mdi:alarm-bell" + }, "animal_detected": { "default": "mdi:paw" }, @@ -85,12 +88,21 @@ "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" }, @@ -133,6 +145,9 @@ "temperature_sensor": { "default": "mdi:thermometer" }, + "temperature_sensor_enabled": { + "default": "mdi:thermometer" + }, "tracking_person": { "default": "mdi:walk" }, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index dad914ff7e8..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 ( @@ -102,8 +102,6 @@ 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( @@ -217,7 +215,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( key="doorbell_text", translation_key="doorbell_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, @@ -377,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/services.py b/homeassistant/components/unifiprotect/services.py index a6b9a1378b2..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 @@ -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/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/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/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/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 9e72ee6dd74..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, @@ -208,14 +207,7 @@ 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 for person in presence_data.values(): @@ -260,13 +252,6 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): 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, self.title_data) def last_seen_timestamp(self, person: Person) -> datetime | None: @@ -285,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/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/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 64c781e5e81..81c0421edd7 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -41,6 +41,7 @@ APPLICATION_CREDENTIALS = [ "tibber", "twitch", "volvo", + "watts", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 23351374cf4..0aa3b8869e3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -278,6 +278,7 @@ FLOWS = { "harmony", "heos", "here_travel_time", + "hikvision", "hisense_aehw4a1", "hive", "hko", @@ -750,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 676b8d7f504..3ea809b08f1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -388,7 +388,7 @@ }, "apcupsd": { "name": "APC UPS Daemon", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1478,7 +1478,7 @@ }, "duke_energy": { "name": "Duke Energy", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1696,7 +1696,7 @@ "name": "emoncms", "integrations": { "emoncms": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling", "name": "Emoncms" @@ -1711,7 +1711,7 @@ }, "emonitor": { "name": "SiteSage Emonitor", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1763,7 +1763,7 @@ }, "enocean": { "name": "EnOcean", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "single_config_entry": true @@ -1812,7 +1812,7 @@ }, "epson": { "name": "Epson", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1835,7 +1835,7 @@ }, "escea": { "name": "Escea", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -1899,7 +1899,7 @@ }, "evil_genius_labs": { "name": "Evil Genius Labs", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -1911,7 +1911,7 @@ }, "faa_delays": { "name": "FAA Delays", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1982,7 +1982,7 @@ }, "fing": { "name": "Fing", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -2000,7 +2000,7 @@ }, "fireservicerota": { "name": "FireServiceRota", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2018,7 +2018,7 @@ }, "fivem": { "name": "FiveM", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -2142,7 +2142,7 @@ }, "foscam": { "name": "Foscam", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2165,7 +2165,7 @@ }, "freebox": { "name": "Freebox", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2415,7 +2415,7 @@ }, "goodwe": { "name": "GoodWe Inverter", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -2465,7 +2465,7 @@ "name": "Google Maps" }, "google_photos": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Photos" @@ -2483,7 +2483,7 @@ "name": "Google Sheets" }, "google_tasks": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Tasks" @@ -2495,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" }, @@ -2547,7 +2547,7 @@ "name": "Govee", "integrations": { "govee_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Govee Bluetooth" @@ -2696,7 +2696,7 @@ }, "here_travel_time": { "name": "HERE Travel Time", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -2714,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" }, @@ -2747,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" }, @@ -2819,7 +2819,7 @@ ] }, "homewizard": { - "name": "HomeWizard Energy", + "name": "HomeWizard", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" @@ -2868,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" }, @@ -2899,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" @@ -2908,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" }, @@ -2932,7 +2932,7 @@ }, "ialarm": { "name": "Antifurto365 iAlarm", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3010,7 +3010,7 @@ }, "imap": { "name": "IMAP", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push" }, @@ -3022,7 +3022,7 @@ }, "imgw_pib": { "name": "IMGW-PIB", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3064,7 +3064,7 @@ }, "inkbird": { "name": "INKBIRD", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3089,7 +3089,7 @@ }, "intellifire": { "name": "IntelliFire", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3118,7 +3118,7 @@ }, "iotawatt": { "name": "IoTaWatt", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3136,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" }, @@ -3176,7 +3176,7 @@ "iot_class": "local_polling" }, "islamic_prayer_times": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "calculated" }, @@ -3187,7 +3187,7 @@ }, "israel_rail": { "name": "Israel Railways", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3200,7 +3200,7 @@ }, "ista_ecotrend": { "name": "ista EcoTrend", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3249,7 +3249,7 @@ }, "justnimbus": { "name": "JustNimbus", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3272,7 +3272,7 @@ }, "kaleidescape": { "name": "Kaleidescape", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3290,7 +3290,7 @@ }, "keenetic_ndms2": { "name": "Keenetic NDMS2 Router", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3302,7 +3302,7 @@ }, "kegtron": { "name": "Kegtron", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3344,7 +3344,7 @@ }, "kmtronic": { "name": "KMtronic", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3363,7 +3363,7 @@ }, "kodi": { "name": "Kodi", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, @@ -3386,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" }, @@ -3403,7 +3403,7 @@ }, "kulersky": { "name": "Kuler Sky", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3439,7 +3439,7 @@ }, "landisgyr_heat_meter": { "name": "Landis+Gyr Heat Meter", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3451,7 +3451,7 @@ }, "lastfm": { "name": "Last.fm", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3482,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" }, @@ -3536,7 +3536,7 @@ "name": "LG Netcast" }, "lg_soundbar": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "LG Soundbars" @@ -3557,7 +3557,7 @@ }, "libre_hardware_monitor": { "name": "Libre Hardware Monitor", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3569,7 +3569,7 @@ }, "lifx": { "name": "LIFX", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3693,7 +3693,7 @@ "name": "Logitech", "integrations": { "harmony": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Logitech Harmony Hub" @@ -3727,7 +3727,7 @@ }, "loqed": { "name": "LOQED Touch Smart Lock", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -3796,7 +3796,7 @@ }, "mailgun": { "name": "Mailgun", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push" }, @@ -3866,7 +3866,7 @@ }, "medcom_ble": { "name": "Medcom Bluetooth", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -3899,7 +3899,7 @@ "name": "Melnor", "integrations": { "melnor": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Melnor Bluetooth" @@ -3932,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" }, @@ -6478,7 +6478,7 @@ "iot_class": "cloud_polling" }, "sun": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "calculated", "single_config_entry": true @@ -7472,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", @@ -7590,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 bb14f51efb6..bd0d55a7a8f 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -338,8 +338,11 @@ class EntityTriggerBase(Trigger): self._target = config.target def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is not an expected target states.""" - return not self.is_valid_state(from_state) + """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_valid_state(self, state: State) -> bool: @@ -390,12 +393,11 @@ 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 new state is not valid - if not to_state or not self.is_valid_state(to_state): + if not self.is_valid_state(to_state): return # The trigger should never fire if the transition is not valid @@ -428,8 +430,8 @@ 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 @@ -438,14 +440,17 @@ class EntityStateTriggerBase(EntityTriggerBase): 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_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_valid_state(self, state: State) -> bool: @@ -453,23 +458,48 @@ class ConditionalEntityStateTriggerBase(EntityTriggerBase): 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_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/package_constraints.txt b/homeassistant/package_constraints.txt index 5651b720920..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 @@ -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 @@ -223,6 +223,3 @@ gql<4.0.0 # Pin pytest-rerunfailures to prevent accidental breaks pytest-rerunfailures==16.0.1 - -# pycares 5.x is not yet compatible with aiodns -pycares==4.11.0 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 ea25fe8dc02..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", 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 0114cb21f3f..304971c709b 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 @@ -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 @@ -548,7 +548,7 @@ asusrouter==1.21.3 # 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 @@ -742,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 @@ -860,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 @@ -1482,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 @@ -1497,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 @@ -1575,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 @@ -1810,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 @@ -1852,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 @@ -1895,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 @@ -1918,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 @@ -1949,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 @@ -2097,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 @@ -2232,10 +2229,10 @@ pynetio==0.1.9.1 pynina==0.3.6 # homeassistant.components.nintendo_parental_controls -pynintendoauth==1.0.0 +pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.1.1 +pynintendoparental==2.1.3 # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -2300,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 @@ -2430,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 @@ -2514,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 @@ -2532,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 @@ -2575,7 +2572,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==3.12.2 +python-roborock==3.19.0 # homeassistant.components.smarttub python-smarttub==0.0.46 @@ -2877,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 @@ -2949,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 @@ -2987,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 @@ -3126,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 @@ -3203,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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 616d14697a4..65f3b552685 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 @@ -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 @@ -515,7 +515,7 @@ asusrouter==1.21.3 # 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 @@ -657,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 @@ -760,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 @@ -1289,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 @@ -1304,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 @@ -1370,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 @@ -1550,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 @@ -1582,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 @@ -1614,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 @@ -1634,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 @@ -1653,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 @@ -1771,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 @@ -1879,10 +1879,10 @@ pynetgear==0.10.10 pynina==0.3.6 # homeassistant.components.nintendo_parental_controls -pynintendoauth==1.0.0 +pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.1.1 +pynintendoparental==2.1.3 # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -1938,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 @@ -2047,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 @@ -2104,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 @@ -2116,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 @@ -2156,7 +2156,7 @@ python-pooldose==0.8.1 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==3.12.2 +python-roborock==3.19.0 # homeassistant.components.smarttub python-smarttub==0.0.46 @@ -2401,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 @@ -2461,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 @@ -2487,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 @@ -2608,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 @@ -2670,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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e61021acc4d..cc89285302a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -214,9 +214,6 @@ gql<4.0.0 # Pin pytest-rerunfailures to prevent accidental breaks pytest-rerunfailures==16.0.1 - -# pycares 5.x is not yet compatible with aiodns -pycares==4.11.0 """ GENERATED_MESSAGE = ( 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 daca80c3b28..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 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/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 index cb3390cbecb..198c1152c06 100644 --- a/tests/components/airpatrol/test_climate.py +++ b/tests/components/airpatrol/test_climate.py @@ -1,7 +1,9 @@ """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 @@ -32,6 +34,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,6 +47,16 @@ from tests.common import ( ) +@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", [ 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/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/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/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/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/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 9119a5e77b9..3273f9c5663 100644 --- a/tests/components/ekeybionyx/conftest.py +++ b/tests/components/ekeybionyx/conftest.py @@ -139,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 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_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 fb5f94f953d..d887d88772f 100644 --- a/tests/components/gentex_homelink/__init__.py +++ b/tests/components/gentex_homelink/__init__.py @@ -3,10 +3,17 @@ 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.""" diff --git a/tests/components/gentex_homelink/conftest.py b/tests/components/gentex_homelink/conftest.py index 0fc9e9f0943..c9adcc6081d 100644 --- a/tests/components/gentex_homelink/conftest.py +++ b/tests/components/gentex_homelink/conftest.py @@ -9,6 +9,8 @@ import pytest from homeassistant.components.gentex_homelink import DOMAIN +from . import TEST_ACCESS_JWT + from tests.common import MockConfigEntry @@ -21,7 +23,7 @@ def mock_srp_auth() -> Generator[AsyncMock]: instance = mock_srp_auth.return_value instance.async_get_access_token.return_value = { "AuthenticationResult": { - "AccessToken": "access", + "AccessToken": TEST_ACCESS_JWT, "RefreshToken": "refresh", "TokenType": "bearer", "ExpiresIn": 3600, @@ -60,6 +62,8 @@ def mock_device() -> AsyncMock: def mock_config_entry() -> MockConfigEntry: """Mock setup entry.""" return MockConfigEntry( + unique_id="some-uuid", + version=1, domain=DOMAIN, data={ "auth_implementation": "gentex_homelink", diff --git a/tests/components/gentex_homelink/test_config_flow.py b/tests/components/gentex_homelink/test_config_flow.py index b8c8ce361f4..4e2bffd3d25 100644 --- a/tests/components/gentex_homelink/test_config_flow.py +++ b/tests/components/gentex_homelink/test_config_flow.py @@ -7,10 +7,13 @@ import pytest from homeassistant.components.gentex_homelink.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import TEST_ACCESS_JWT, TEST_CREDENTIALS, setup_integration + +from tests.common import MockConfigEntry + async def test_full_flow( hass: HomeAssistant, mock_srp_auth: AsyncMock, mock_setup_entry: AsyncMock @@ -26,13 +29,13 @@ async def test_full_flow( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "SomePassword"}, + user_input=TEST_CREDENTIALS, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "auth_implementation": "gentex_homelink", "token": { - "access_token": "access", + "access_token": TEST_ACCESS_JWT, "refresh_token": "refresh", "expires_in": 3600, "token_type": "bearer", @@ -40,6 +43,31 @@ async def test_full_flow( }, } assert result["title"] == "SRPAuth" + assert result["result"].unique_id == "some-uuid" + + +async def test_unique_configurations( + hass: HomeAssistant, + mock_srp_auth: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Check full flow.""" + 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" @pytest.mark.parametrize( @@ -69,7 +97,7 @@ async def test_exceptions( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "SomePassword"}, + user_input=TEST_CREDENTIALS, ) assert result["type"] is FlowResultType.FORM @@ -79,6 +107,6 @@ async def test_exceptions( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "SomePassword"}, + user_input=TEST_CREDENTIALS, ) assert result["type"] is FlowResultType.CREATE_ENTRY 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_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_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/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_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/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/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 8fe555674f3..d92fca32c3b 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -108,6 +108,7 @@ async def integration_fixture( "haojai_switch", "heiman_motion_sensor_m1", "humidity_sensor", + "ikea_air_quality_monitor", "ikea_scroll_wheel", "inovelli_vtm30", "laundry_dryer", 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/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index d5a0ca1bc23..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({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index fff6934ee42..126acb631bb 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -250,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, @@ -687,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, @@ -866,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, @@ -2514,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, @@ -3769,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, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index fa547ed7197..f9a2a1fdf08 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6512,6 +6512,287 @@ '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({ 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/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/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index e97fd583db7..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 @@ -175,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({ @@ -229,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({ @@ -337,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 42a0eccf13b..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([ 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/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/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/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/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/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/test_init.py b/tests/components/plugwise/test_init.py index 18640c6f6c1..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, ) @@ -334,3 +336,31 @@ async def test_update_device( 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/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 8ed1ebaad16..034d8b3c1f9 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -233,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 @@ -246,6 +247,7 @@ async def test_stale_device( "Roborock S7 2 Dock", "Dyad Pro", "Zeo One", + "Roborock Q7", } @@ -269,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) @@ -283,6 +286,7 @@ async def test_no_stale_device( "Roborock S7 2 Dock", "Dyad Pro", "Zeo One", + "Roborock Q7", } @@ -440,6 +444,7 @@ async def test_zeo_device_fails_setup( "Roborock S7 2", "Roborock S7 2 Dock", "Dyad Pro", + "Roborock Q7", # Zeo device is missing } @@ -476,4 +481,5 @@ async def test_dyad_device_fails_setup( "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/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/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/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 8c73379a6e0..1118dea6c59 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -99,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 @@ -158,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", @@ -414,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, @@ -421,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( @@ -1505,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", 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/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 ebc7e5ec114..3007ef34e13 100644 --- a/tests/components/tibber/test_init.py +++ b/tests/components/tibber/test_init.py @@ -19,13 +19,12 @@ async def test_entry_unload( ) -> None: """Test unloading the entry.""" entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "tibber") - assert entry is not None - 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 @pytest.mark.usefixtures("recorder_mock") 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/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 dd8c7f6dfe9..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) @@ -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/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/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/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_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 10c02c8e856..bb20281ccf6 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -5,9 +5,21 @@ 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 @@ -16,7 +28,7 @@ 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 @@ -227,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", @@ -260,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_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/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"}